Merge pull request #88 from tldraw/refactor-shape-utils
[refactor] Shape Utils
This commit is contained in:
commit
cc6c486918
69 changed files with 2090 additions and 2029 deletions
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSvg } from '+test'
|
||||
import { Binding } from './binding'
|
||||
|
||||
jest.spyOn(console, 'error').mockImplementation(() => void null)
|
||||
|
||||
describe('binding', () => {
|
||||
test('mounts component without crashing', () => {
|
||||
renderWithSvg(<Binding point={[0, 0]} type={'anchor'} />)
|
||||
render(<Binding point={[0, 0]} type={'anchor'} />)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSvg } from '+test'
|
||||
import { Bounds } from './bounds'
|
||||
|
||||
describe('bounds', () => {
|
||||
test('mounts component without crashing', () => {
|
||||
renderWithSvg(
|
||||
render(
|
||||
<Bounds
|
||||
zoom={1}
|
||||
bounds={{ minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as React from 'react'
|
||||
import type {
|
||||
TLShape,
|
||||
TLPage,
|
||||
TLPageState,
|
||||
TLCallbacks,
|
||||
TLShapeUtils,
|
||||
TLTheme,
|
||||
TLBounds,
|
||||
TLBinding,
|
||||
|
@ -12,16 +12,15 @@ import type {
|
|||
import { Canvas } from '../canvas'
|
||||
import { Inputs } from '../../inputs'
|
||||
import { useTLTheme, TLContext, TLContextType } from '../../hooks'
|
||||
import type { TLShapeUtil } from '+index'
|
||||
|
||||
export interface RendererProps<
|
||||
T extends TLShape,
|
||||
E extends Element,
|
||||
M extends Record<string, unknown>
|
||||
> extends Partial<TLCallbacks<T>> {
|
||||
export interface RendererProps<T extends TLShape, E extends Element = any, M = any>
|
||||
extends Partial<TLCallbacks<T>> {
|
||||
/**
|
||||
* An object containing instances of your shape classes.
|
||||
*/
|
||||
shapeUtils: TLShapeUtils<T, E>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
shapeUtils: Record<T['type'], TLShapeUtil<T, E, M>>
|
||||
/**
|
||||
* The current page, containing shapes and bindings.
|
||||
*/
|
||||
|
@ -89,7 +88,7 @@ export function Renderer<T extends TLShape, E extends Element, M extends Record<
|
|||
|
||||
rest
|
||||
|
||||
const [context] = React.useState<TLContextType<T, E>>(() => ({
|
||||
const [context] = React.useState<TLContextType<T, E, M>>(() => ({
|
||||
callbacks: rest,
|
||||
shapeUtils,
|
||||
rScreenBounds,
|
||||
|
|
|
@ -17,7 +17,9 @@ export const ShapeIndicator = React.memo(
|
|||
}
|
||||
>
|
||||
<svg width="100%" height="100%">
|
||||
<g className="tl-centered-g">{utils.renderIndicator(shape)}</g>
|
||||
<g className="tl-centered-g">
|
||||
<utils.Indicator shape={shape} />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -2,8 +2,14 @@
|
|||
import * as React from 'react'
|
||||
import type { TLShapeUtil, TLRenderInfo, TLShape } from '+types'
|
||||
|
||||
interface RenderedShapeProps<T extends TLShape, E extends Element, M>
|
||||
extends TLRenderInfo<T, E, M> {
|
||||
shape: T
|
||||
utils: TLShapeUtil<T, E, M>
|
||||
}
|
||||
|
||||
export const RenderedShape = React.memo(
|
||||
<T extends TLShape, E extends Element, M extends Record<string, unknown>>({
|
||||
<T extends TLShape, E extends Element, M>({
|
||||
shape,
|
||||
utils,
|
||||
isEditing,
|
||||
|
@ -15,14 +21,11 @@ export const RenderedShape = React.memo(
|
|||
onShapeBlur,
|
||||
events,
|
||||
meta,
|
||||
}: TLRenderInfo<T, M, E> & {
|
||||
shape: T
|
||||
utils: TLShapeUtil<T, E>
|
||||
}) => {
|
||||
}: RenderedShapeProps<T, E, M>) => {
|
||||
const ref = utils.getRef(shape)
|
||||
|
||||
return (
|
||||
<utils.render
|
||||
<utils._Component
|
||||
ref={ref}
|
||||
shape={shape}
|
||||
isEditing={isEditing}
|
||||
|
|
|
@ -2,8 +2,12 @@ import * as React from 'react'
|
|||
import type { IShapeTreeNode, TLShape, TLShapeUtils } from '+types'
|
||||
import { Shape } from './shape'
|
||||
|
||||
interface ShapeNodeProps<T extends TLShape, E extends Element> extends IShapeTreeNode<T> {
|
||||
utils: TLShapeUtils<T, E>
|
||||
}
|
||||
|
||||
export const ShapeNode = React.memo(
|
||||
({
|
||||
<T extends TLShape, E extends Element>({
|
||||
shape,
|
||||
utils,
|
||||
children,
|
||||
|
@ -13,7 +17,7 @@ export const ShapeNode = React.memo(
|
|||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
}: { utils: TLShapeUtils<TLShape, Element> } & IShapeTreeNode<TLShape, any>) => {
|
||||
}: ShapeNodeProps<T, E>) => {
|
||||
return (
|
||||
<>
|
||||
<Shape
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import * as React from 'react'
|
||||
import { mockUtils, renderWithSvg } from '+test'
|
||||
import { mockUtils, renderWithContext } from '+test'
|
||||
import { Shape } from './shape'
|
||||
|
||||
describe('shape', () => {
|
||||
test('mounts component without crashing', () => {
|
||||
renderWithSvg(
|
||||
renderWithContext(
|
||||
<Shape
|
||||
shape={mockUtils.box.create({ id: 'box' })}
|
||||
utils={mockUtils[mockUtils.box.type]}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import { useShapeEvents } from '+hooks'
|
||||
|
@ -7,7 +6,11 @@ import { RenderedShape } from './rendered-shape'
|
|||
import { Container } from '+components/container'
|
||||
import { useTLContext } from '+hooks'
|
||||
|
||||
export const Shape = <T extends TLShape, E extends Element, M extends Record<string, unknown>>({
|
||||
interface ShapeProps<T extends TLShape, E extends Element, M> extends IShapeTreeNode<T, M> {
|
||||
utils: TLShapeUtil<T, E, M>
|
||||
}
|
||||
|
||||
export const Shape = <T extends TLShape, E extends Element, M>({
|
||||
shape,
|
||||
utils,
|
||||
isEditing,
|
||||
|
@ -16,9 +19,7 @@ export const Shape = <T extends TLShape, E extends Element, M extends Record<str
|
|||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
}: IShapeTreeNode<T, M> & {
|
||||
utils: TLShapeUtil<T, E>
|
||||
}) => {
|
||||
}: ShapeProps<T, E, M>) => {
|
||||
const { callbacks } = useTLContext()
|
||||
const bounds = utils.getBounds(shape)
|
||||
const events = useShapeEvents(shape.id, isCurrentParent)
|
||||
|
@ -38,7 +39,7 @@ export const Shape = <T extends TLShape, E extends Element, M extends Record<str
|
|||
isHovered={isHovered}
|
||||
isSelected={isSelected}
|
||||
utils={utils as any}
|
||||
meta={meta as any}
|
||||
meta={meta}
|
||||
events={events}
|
||||
onShapeChange={callbacks.onShapeChange}
|
||||
/>
|
||||
|
|
|
@ -2,10 +2,10 @@ import * as React from 'react'
|
|||
import type { Inputs } from '+inputs'
|
||||
import type { TLCallbacks, TLShape, TLBounds, TLPageState, TLShapeUtils } from '+types'
|
||||
|
||||
export interface TLContextType<T extends TLShape, E extends Element> {
|
||||
export interface TLContextType<T extends TLShape, E extends Element, M = any> {
|
||||
id?: string
|
||||
callbacks: Partial<TLCallbacks<T>>
|
||||
shapeUtils: TLShapeUtils<T, E>
|
||||
shapeUtils: TLShapeUtils<T, E, M>
|
||||
rPageState: React.MutableRefObject<TLPageState>
|
||||
rScreenBounds: React.MutableRefObject<TLBounds | null>
|
||||
inputs: Inputs
|
||||
|
|
|
@ -2,3 +2,4 @@ export * from './components'
|
|||
export * from './types'
|
||||
export * from './utils'
|
||||
export * from './inputs'
|
||||
export * from './shapes'
|
||||
|
|
147
packages/core/src/shapes/createShape.spec.tsx
Normal file
147
packages/core/src/shapes/createShape.spec.tsx
Normal file
|
@ -0,0 +1,147 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as React from 'react'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type { TLShape, TLBounds } from '+types'
|
||||
import { ShapeUtil } from './createShape'
|
||||
import { render } from '@testing-library/react'
|
||||
import { SVGContainer } from '+components'
|
||||
import Utils from '+utils'
|
||||
|
||||
export interface BoxShape extends TLShape {
|
||||
size: number[]
|
||||
}
|
||||
|
||||
export const Box = new ShapeUtil<BoxShape, SVGSVGElement, null, { age: number }>(() => {
|
||||
return {
|
||||
age: 100,
|
||||
type: 'box',
|
||||
defaultProps: {
|
||||
id: 'example1',
|
||||
type: 'box',
|
||||
parentId: 'page',
|
||||
childIndex: 0,
|
||||
name: 'Example Shape',
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
rotation: 0,
|
||||
},
|
||||
Component({ shape, events, meta }, ref) {
|
||||
return (
|
||||
<SVGContainer ref={ref}>
|
||||
<g {...events}>
|
||||
<rect width={shape.size[0]} height={shape.size[1]} fill="none" stroke="black" />
|
||||
</g>
|
||||
</SVGContainer>
|
||||
)
|
||||
},
|
||||
Indicator({ shape }) {
|
||||
return (
|
||||
<SVGContainer>
|
||||
<rect width={shape.size[0]} height={shape.size[1]} fill="none" stroke="black" />
|
||||
</SVGContainer>
|
||||
)
|
||||
},
|
||||
getBounds(shape) {
|
||||
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
const [width, height] = shape.size
|
||||
return {
|
||||
minX: 0,
|
||||
maxX: width,
|
||||
minY: 0,
|
||||
maxY: height,
|
||||
width,
|
||||
height,
|
||||
} as TLBounds
|
||||
})
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
},
|
||||
getRotatedBounds(shape) {
|
||||
return {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 100,
|
||||
maxY: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}
|
||||
},
|
||||
shouldRender(prev, next) {
|
||||
return prev.point !== next.point
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const boxShape = {
|
||||
id: 'example1',
|
||||
type: 'box',
|
||||
parentId: 'page',
|
||||
childIndex: 0,
|
||||
name: 'Example Shape',
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
rotation: 0,
|
||||
}
|
||||
|
||||
const box = Box.create({ id: 'box1' })
|
||||
|
||||
describe('shape utils', () => {
|
||||
it('creates a shape utils', () => {
|
||||
expect(Box).toBeTruthy()
|
||||
})
|
||||
|
||||
it('creates a shape utils with extended properties', () => {
|
||||
expect(Box.age).toBe(100)
|
||||
})
|
||||
|
||||
it('creates a shape', () => {
|
||||
expect(Box.create({ id: 'box1' })).toStrictEqual({
|
||||
...boxShape,
|
||||
id: 'box1',
|
||||
})
|
||||
})
|
||||
|
||||
it('sets config', () => {
|
||||
const bounds = Box.getRotatedBounds(box)
|
||||
expect(bounds).toStrictEqual({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 100,
|
||||
maxY: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
})
|
||||
})
|
||||
|
||||
test('accesses this in an override method', () => {
|
||||
expect(Box.shouldRender(box, { ...box, point: [1, 1] })).toBeTruthy()
|
||||
})
|
||||
|
||||
test('mounts component without crashing', () => {
|
||||
const box = Box.create({ id: 'box1' })
|
||||
const ref = React.createRef<SVGSVGElement>()
|
||||
|
||||
const ref2 = React.createRef<HTMLDivElement>()
|
||||
|
||||
const H = React.forwardRef<HTMLDivElement, { message: string }>((props, ref) => {
|
||||
return <div ref={ref2}>{props.message}</div>
|
||||
})
|
||||
|
||||
render(<H message="Hello" />)
|
||||
|
||||
render(
|
||||
<Box._Component
|
||||
ref={ref}
|
||||
shape={box}
|
||||
isEditing={false}
|
||||
isBinding={false}
|
||||
isHovered={false}
|
||||
isSelected={false}
|
||||
isCurrentParent={false}
|
||||
meta={{} as any}
|
||||
events={{} as any}
|
||||
/>
|
||||
)
|
||||
})
|
||||
})
|
225
packages/core/src/shapes/createShape.tsx
Normal file
225
packages/core/src/shapes/createShape.tsx
Normal file
|
@ -0,0 +1,225 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as React from 'react'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import type { TLShape, TLShapeUtil } from '+types'
|
||||
import Utils from '+utils'
|
||||
import { intersectPolylineBounds, intersectRayBounds } from '@tldraw/intersect'
|
||||
|
||||
export const ShapeUtil = function <T extends TLShape, E extends Element, M = any, K = unknown>(
|
||||
this: TLShapeUtil<T, E, M> & K,
|
||||
fn: (
|
||||
this: TLShapeUtil<T, E, M> & K
|
||||
) => Partial<TLShapeUtil<T, E, M>> &
|
||||
Pick<TLShapeUtil<T, E, M>, 'type' | 'defaultProps' | 'Component' | 'Indicator' | 'getBounds'> &
|
||||
K
|
||||
): TLShapeUtil<T, E, M> & ReturnType<typeof fn> {
|
||||
const defaults: Partial<TLShapeUtil<T, E, M>> = {
|
||||
refMap: new Map(),
|
||||
|
||||
boundsCache: new WeakMap(),
|
||||
|
||||
canEdit: false,
|
||||
|
||||
canBind: false,
|
||||
|
||||
isAspectRatioLocked: false,
|
||||
|
||||
create: (props) => {
|
||||
this.refMap.set(props.id, React.createRef())
|
||||
const defaults = this.defaultProps
|
||||
return { ...defaults, ...props }
|
||||
},
|
||||
|
||||
getRef: (shape) => {
|
||||
if (!this.refMap.has(shape.id)) {
|
||||
this.refMap.set(shape.id, React.createRef<E>())
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return this.refMap.get(shape.id)!
|
||||
},
|
||||
|
||||
mutate: (shape, props) => {
|
||||
return { ...shape, ...props }
|
||||
},
|
||||
|
||||
transform: (shape, bounds) => {
|
||||
return { ...shape, point: [bounds.minX, bounds.minY] }
|
||||
},
|
||||
|
||||
transformSingle: (shape, bounds, info) => {
|
||||
return this.transform(shape, bounds, info)
|
||||
},
|
||||
|
||||
shouldRender: () => {
|
||||
return true
|
||||
},
|
||||
|
||||
getRotatedBounds: (shape) => {
|
||||
return Utils.getBoundsFromPoints(
|
||||
Utils.getRotatedCorners(this.getBounds(shape), shape.rotation)
|
||||
)
|
||||
},
|
||||
|
||||
getCenter: (shape) => {
|
||||
return Utils.getBoundsCenter(this.getBounds(shape))
|
||||
},
|
||||
|
||||
hitTest: (shape, point) => {
|
||||
return Utils.pointInBounds(point, this.getBounds(shape))
|
||||
},
|
||||
|
||||
hitTestBounds: (shape, bounds) => {
|
||||
const { minX, minY, maxX, maxY, width, height } = this.getBounds(shape)
|
||||
const center = [minX + width / 2, minY + height / 2]
|
||||
|
||||
const corners = [
|
||||
[minX, minY],
|
||||
[maxX, minY],
|
||||
[maxX, maxY],
|
||||
[minX, maxY],
|
||||
].map((point) => Vec.rotWith(point, center, shape.rotation || 0))
|
||||
|
||||
return (
|
||||
corners.every(
|
||||
(point) =>
|
||||
!(
|
||||
point[0] < bounds.minX ||
|
||||
point[0] > bounds.maxX ||
|
||||
point[1] < bounds.minY ||
|
||||
point[1] > bounds.maxY
|
||||
)
|
||||
) || intersectPolylineBounds(corners, bounds).length > 0
|
||||
)
|
||||
},
|
||||
|
||||
getBindingPoint: (shape, fromShape, point, origin, direction, padding, bindAnywhere) => {
|
||||
// Algorithm time! We need to find the binding point (a normalized point inside of the shape, or around the shape, where the arrow will point to) and the distance from the binding shape to the anchor.
|
||||
|
||||
let bindingPoint: number[]
|
||||
|
||||
let distance: number
|
||||
|
||||
const bounds = this.getBounds(shape)
|
||||
|
||||
const expandedBounds = Utils.expandBounds(bounds, padding)
|
||||
|
||||
// The point must be inside of the expanded bounding box
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return
|
||||
|
||||
// The point is inside of the shape, so we'll assume the user is indicating a specific point inside of the shape.
|
||||
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 {
|
||||
// (1) Binding point
|
||||
|
||||
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
|
||||
|
||||
const intersection = intersectRayBounds(origin, direction, expandedBounds)
|
||||
.filter((int) => int.didIntersect)
|
||||
.map((int) => int.points[0])
|
||||
.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
|
||||
|
||||
// The anchor is a point between the handle and the intersection
|
||||
const anchor = Vec.med(point, intersection)
|
||||
|
||||
// If we're close to the center, snap to the center, or else calculate a normalized point based on the anchor and the expanded bounds.
|
||||
|
||||
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
|
||||
bindingPoint = [0.5, 0.5]
|
||||
} else {
|
||||
//
|
||||
bindingPoint = Vec.divV(Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]), [
|
||||
expandedBounds.width,
|
||||
expandedBounds.height,
|
||||
])
|
||||
}
|
||||
|
||||
// (3) Distance
|
||||
|
||||
// If the point is inside of the bounds, set the distance to a fixed value.
|
||||
if (Utils.pointInBounds(point, bounds)) {
|
||||
distance = 16
|
||||
} else {
|
||||
// If the binding point was close to the shape's center, snap to to the center. Find the distance between the point and the real bounds of the shape
|
||||
distance = Math.max(
|
||||
16,
|
||||
Utils.getBoundsSides(bounds)
|
||||
.map((side) => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
|
||||
.sort((a, b) => a - b)[0]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
}
|
||||
},
|
||||
|
||||
onDoubleClickBoundsHandle() {
|
||||
return
|
||||
},
|
||||
|
||||
onDoubleClickHandle() {
|
||||
return
|
||||
},
|
||||
|
||||
onHandleChange() {
|
||||
return
|
||||
},
|
||||
|
||||
onRightPointHandle() {
|
||||
return
|
||||
},
|
||||
|
||||
onSessionComplete() {
|
||||
return
|
||||
},
|
||||
|
||||
onStyleChange() {
|
||||
return
|
||||
},
|
||||
|
||||
onBindingChange() {
|
||||
return
|
||||
},
|
||||
|
||||
onChildrenChange() {
|
||||
return
|
||||
},
|
||||
|
||||
updateChildren() {
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
Object.assign(this, defaults)
|
||||
Object.assign(this, fn.call(this))
|
||||
Object.assign(this, fn.call(this))
|
||||
|
||||
this.getBounds = this.getBounds.bind(this)
|
||||
this.Component = this.Component.bind(this)
|
||||
this._Component = React.forwardRef(this.Component)
|
||||
|
||||
return this
|
||||
} as unknown as {
|
||||
new <T extends TLShape, E extends Element, M = any, K = unknown>(
|
||||
fn: (
|
||||
this: TLShapeUtil<T, E, M>
|
||||
) => Partial<TLShapeUtil<T, E, M>> &
|
||||
Pick<
|
||||
TLShapeUtil<T, E, M>,
|
||||
'type' | 'defaultProps' | 'Component' | 'Indicator' | 'getBounds'
|
||||
> &
|
||||
K
|
||||
): TLShapeUtil<T, E, M> & ReturnType<typeof fn>
|
||||
}
|
1
packages/core/src/shapes/index.ts
Normal file
1
packages/core/src/shapes/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './createShape'
|
|
@ -1,7 +0,0 @@
|
|||
import { Box } from './box'
|
||||
|
||||
describe('example shape', () => {
|
||||
it('should create an instance', () => {
|
||||
expect(new Box()).toBeTruthy()
|
||||
})
|
||||
})
|
|
@ -1,72 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import * as React from 'react'
|
||||
import { TLShapeUtil, TLShape, TLShapeProps, TLBounds, TLRenderInfo, TLTransformInfo } from '+types'
|
||||
import Utils from '+utils'
|
||||
|
||||
export interface BoxShape extends TLShape {
|
||||
size: number[]
|
||||
}
|
||||
|
||||
export class Box extends TLShapeUtil<BoxShape, SVGGElement> {
|
||||
type = 'box'
|
||||
|
||||
defaultProps = {
|
||||
id: 'example1',
|
||||
type: 'box',
|
||||
parentId: 'page',
|
||||
childIndex: 0,
|
||||
name: 'Example Shape',
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
rotation: 0,
|
||||
}
|
||||
|
||||
render = React.forwardRef<SVGGElement, TLShapeProps<BoxShape, SVGGElement>>(
|
||||
({ shape, events }, ref) => {
|
||||
return (
|
||||
<g ref={ref} {...events}>
|
||||
<rect width={shape.size[0]} height={shape.size[1]} fill="none" stroke="black" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
renderIndicator(shape: BoxShape) {
|
||||
return <rect width={100} height={100} />
|
||||
}
|
||||
|
||||
shouldRender(prev: BoxShape, next: BoxShape): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
getBounds(shape: BoxShape): TLBounds {
|
||||
return Utils.getFromCache(this.boundsCache, shape, () => ({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 0,
|
||||
maxY: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}))
|
||||
}
|
||||
|
||||
getRotatedBounds(shape: BoxShape) {
|
||||
return Utils.getBoundsFromPoints(Utils.getRotatedCorners(this.getBounds(shape), shape.rotation))
|
||||
}
|
||||
|
||||
getCenter(shape: BoxShape): number[] {
|
||||
return Utils.getBoundsCenter(this.getBounds(shape))
|
||||
}
|
||||
|
||||
hitTest(shape: BoxShape, point: number[]) {
|
||||
return Utils.pointInBounds(point, this.getBounds(shape))
|
||||
}
|
||||
|
||||
transform(shape: BoxShape, bounds: TLBounds, _info: TLTransformInfo<BoxShape>): BoxShape {
|
||||
return { ...shape, point: [bounds.minX, bounds.minY] }
|
||||
}
|
||||
|
||||
transformSingle(shape: BoxShape, bounds: TLBounds, info: TLTransformInfo<BoxShape>): BoxShape {
|
||||
return this.transform(shape, bounds, info)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
export * from './box'
|
||||
export * from './mockDocument'
|
||||
export * from './mockUtils'
|
||||
export * from './renderWithContext'
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import type { TLBinding, TLPage, TLPageState } from '+types'
|
||||
import type { BoxShape } from './box'
|
||||
import type { TLBinding, TLPage, TLPageState, TLShape } from '+types'
|
||||
|
||||
interface BoxShape extends TLShape {
|
||||
size: number[]
|
||||
}
|
||||
|
||||
export const mockDocument: { page: TLPage<BoxShape, TLBinding>; pageState: TLPageState } = {
|
||||
page: {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { TLShapeUtils } from '+types'
|
||||
import { Box, BoxShape } from './box'
|
||||
import { Box } from '../shapes/createShape.spec'
|
||||
|
||||
export const mockUtils: TLShapeUtils<BoxShape, SVGGElement> = {
|
||||
box: new Box(),
|
||||
export const mockUtils: TLShapeUtils = {
|
||||
box: Box,
|
||||
}
|
||||
|
|
264
packages/core/src/types.d.ts
vendored
Normal file
264
packages/core/src/types.d.ts
vendored
Normal file
|
@ -0,0 +1,264 @@
|
|||
import type React from 'react';
|
||||
import type { ForwardedRef } from 'react';
|
||||
export declare type Patch<T> = Partial<{
|
||||
[P in keyof T]: T | Partial<T> | Patch<T[P]>;
|
||||
}>;
|
||||
export interface TLPage<T extends TLShape, B extends TLBinding> {
|
||||
id: string;
|
||||
name?: string;
|
||||
childIndex?: number;
|
||||
shapes: Record<string, T>;
|
||||
bindings: Record<string, B>;
|
||||
}
|
||||
export interface TLPageState {
|
||||
id: string;
|
||||
selectedIds: string[];
|
||||
camera: {
|
||||
point: number[];
|
||||
zoom: number;
|
||||
};
|
||||
brush?: TLBounds;
|
||||
pointedId?: string | null;
|
||||
hoveredId?: string | null;
|
||||
editingId?: string | null;
|
||||
bindingId?: string | null;
|
||||
boundsRotation?: number;
|
||||
currentParentId?: string | null;
|
||||
}
|
||||
export interface TLHandle {
|
||||
id: string;
|
||||
index: number;
|
||||
point: number[];
|
||||
canBind?: boolean;
|
||||
bindingId?: string;
|
||||
}
|
||||
export interface TLShape {
|
||||
id: string;
|
||||
type: string;
|
||||
parentId: string;
|
||||
childIndex: number;
|
||||
name: string;
|
||||
point: number[];
|
||||
rotation?: number;
|
||||
children?: string[];
|
||||
handles?: Record<string, TLHandle>;
|
||||
isLocked?: boolean;
|
||||
isHidden?: boolean;
|
||||
isEditing?: boolean;
|
||||
isGenerated?: boolean;
|
||||
isAspectRatioLocked?: boolean;
|
||||
}
|
||||
export declare type TLShapeUtils<T extends TLShape = any, E extends Element = any, M = any, K = any> = Record<string, TLShapeUtil<T, E, M, K>>;
|
||||
export interface TLRenderInfo<T extends TLShape, E = any, M = any> {
|
||||
shape: T;
|
||||
isEditing: boolean;
|
||||
isBinding: boolean;
|
||||
isHovered: boolean;
|
||||
isSelected: boolean;
|
||||
isCurrentParent: boolean;
|
||||
meta: M extends any ? M : never;
|
||||
onShapeChange?: TLCallbacks<T>['onShapeChange'];
|
||||
onShapeBlur?: TLCallbacks<T>['onShapeBlur'];
|
||||
events: {
|
||||
onPointerDown: (e: React.PointerEvent<E>) => void;
|
||||
onPointerUp: (e: React.PointerEvent<E>) => void;
|
||||
onPointerEnter: (e: React.PointerEvent<E>) => void;
|
||||
onPointerMove: (e: React.PointerEvent<E>) => void;
|
||||
onPointerLeave: (e: React.PointerEvent<E>) => void;
|
||||
};
|
||||
}
|
||||
export interface TLShapeProps<T extends TLShape, E = any, M = any> extends TLRenderInfo<T, E, M> {
|
||||
ref: ForwardedRef<E>;
|
||||
shape: T;
|
||||
}
|
||||
export interface TLTool {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
export interface TLBinding<M = any> {
|
||||
id: string;
|
||||
type: string;
|
||||
toId: string;
|
||||
fromId: string;
|
||||
meta: M;
|
||||
}
|
||||
export interface TLTheme {
|
||||
brushFill?: string;
|
||||
brushStroke?: string;
|
||||
selectFill?: string;
|
||||
selectStroke?: string;
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
}
|
||||
export declare type TLWheelEventHandler = (info: TLPointerInfo<string>, e: React.WheelEvent<Element> | WheelEvent) => void;
|
||||
export declare type TLPinchEventHandler = (info: TLPointerInfo<string>, e: React.WheelEvent<Element> | WheelEvent | React.TouchEvent<Element> | TouchEvent | React.PointerEvent<Element> | PointerEventInit) => void;
|
||||
export declare type TLPointerEventHandler = (info: TLPointerInfo<string>, e: React.PointerEvent) => void;
|
||||
export declare type TLCanvasEventHandler = (info: TLPointerInfo<'canvas'>, e: React.PointerEvent) => void;
|
||||
export declare type TLBoundsEventHandler = (info: TLPointerInfo<'bounds'>, e: React.PointerEvent) => void;
|
||||
export declare type TLBoundsHandleEventHandler = (info: TLPointerInfo<TLBoundsCorner | TLBoundsEdge | 'rotate'>, e: React.PointerEvent) => void;
|
||||
export interface TLCallbacks<T extends TLShape> {
|
||||
onPinchStart: TLPinchEventHandler;
|
||||
onPinchEnd: TLPinchEventHandler;
|
||||
onPinch: TLPinchEventHandler;
|
||||
onPan: TLWheelEventHandler;
|
||||
onZoom: TLWheelEventHandler;
|
||||
onPointerMove: TLPointerEventHandler;
|
||||
onPointerUp: TLPointerEventHandler;
|
||||
onPointerDown: TLPointerEventHandler;
|
||||
onPointCanvas: TLCanvasEventHandler;
|
||||
onDoubleClickCanvas: TLCanvasEventHandler;
|
||||
onRightPointCanvas: TLCanvasEventHandler;
|
||||
onDragCanvas: TLCanvasEventHandler;
|
||||
onReleaseCanvas: TLCanvasEventHandler;
|
||||
onPointShape: TLPointerEventHandler;
|
||||
onDoubleClickShape: TLPointerEventHandler;
|
||||
onRightPointShape: TLPointerEventHandler;
|
||||
onDragShape: TLPointerEventHandler;
|
||||
onHoverShape: TLPointerEventHandler;
|
||||
onUnhoverShape: TLPointerEventHandler;
|
||||
onReleaseShape: TLPointerEventHandler;
|
||||
onPointBounds: TLBoundsEventHandler;
|
||||
onDoubleClickBounds: TLBoundsEventHandler;
|
||||
onRightPointBounds: TLBoundsEventHandler;
|
||||
onDragBounds: TLBoundsEventHandler;
|
||||
onHoverBounds: TLBoundsEventHandler;
|
||||
onUnhoverBounds: TLBoundsEventHandler;
|
||||
onReleaseBounds: TLBoundsEventHandler;
|
||||
onPointBoundsHandle: TLBoundsHandleEventHandler;
|
||||
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler;
|
||||
onRightPointBoundsHandle: TLBoundsHandleEventHandler;
|
||||
onDragBoundsHandle: TLBoundsHandleEventHandler;
|
||||
onHoverBoundsHandle: TLBoundsHandleEventHandler;
|
||||
onUnhoverBoundsHandle: TLBoundsHandleEventHandler;
|
||||
onReleaseBoundsHandle: TLBoundsHandleEventHandler;
|
||||
onPointHandle: TLPointerEventHandler;
|
||||
onDoubleClickHandle: TLPointerEventHandler;
|
||||
onRightPointHandle: TLPointerEventHandler;
|
||||
onDragHandle: TLPointerEventHandler;
|
||||
onHoverHandle: TLPointerEventHandler;
|
||||
onUnhoverHandle: TLPointerEventHandler;
|
||||
onReleaseHandle: TLPointerEventHandler;
|
||||
onRenderCountChange: (ids: string[]) => void;
|
||||
onShapeChange: (shape: {
|
||||
id: string;
|
||||
} & Partial<T>) => void;
|
||||
onShapeBlur: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
export interface TLBounds {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
}
|
||||
export declare type TLIntersection = {
|
||||
didIntersect: boolean;
|
||||
message: string;
|
||||
points: number[][];
|
||||
};
|
||||
export declare enum TLBoundsEdge {
|
||||
Top = "top_edge",
|
||||
Right = "right_edge",
|
||||
Bottom = "bottom_edge",
|
||||
Left = "left_edge"
|
||||
}
|
||||
export declare enum TLBoundsCorner {
|
||||
TopLeft = "top_left_corner",
|
||||
TopRight = "top_right_corner",
|
||||
BottomRight = "bottom_right_corner",
|
||||
BottomLeft = "bottom_left_corner"
|
||||
}
|
||||
export interface TLPointerInfo<T extends string = string> {
|
||||
target: T;
|
||||
pointerId: number;
|
||||
origin: number[];
|
||||
point: number[];
|
||||
delta: number[];
|
||||
pressure: number;
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
altKey: boolean;
|
||||
}
|
||||
export interface TLKeyboardInfo {
|
||||
origin: number[];
|
||||
point: number[];
|
||||
key: string;
|
||||
keys: string[];
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
altKey: boolean;
|
||||
}
|
||||
export interface TLTransformInfo<T extends TLShape> {
|
||||
type: TLBoundsEdge | TLBoundsCorner;
|
||||
initialShape: T;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
transformOrigin: number[];
|
||||
}
|
||||
export interface TLBezierCurveSegment {
|
||||
start: number[];
|
||||
tangentStart: number[];
|
||||
normalStart: number[];
|
||||
pressureStart: number;
|
||||
end: number[];
|
||||
tangentEnd: number[];
|
||||
normalEnd: number[];
|
||||
pressureEnd: number;
|
||||
}
|
||||
export declare type TLShapeUtil<T extends TLShape, E extends Element, M = any, K = {
|
||||
[key: string]: any;
|
||||
}> = K & {
|
||||
type: T['type'];
|
||||
defaultProps: T;
|
||||
Component(this: TLShapeUtil<T, E, M>, props: TLRenderInfo<T, E, M>, ref: React.ForwardedRef<E>): React.ReactElement<TLRenderInfo<T, E, M>, E['tagName']>;
|
||||
Indicator(this: TLShapeUtil<T, E, M>, props: {
|
||||
shape: T;
|
||||
}): React.ReactElement | null;
|
||||
getBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds;
|
||||
refMap: Map<string, React.RefObject<E>>;
|
||||
boundsCache: WeakMap<TLShape, TLBounds>;
|
||||
isAspectRatioLocked: boolean;
|
||||
canEdit: boolean;
|
||||
canBind: boolean;
|
||||
getRotatedBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds;
|
||||
hitTest(this: TLShapeUtil<T, E, M>, shape: T, point: number[]): boolean;
|
||||
hitTestBounds(this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds): boolean;
|
||||
shouldRender(this: TLShapeUtil<T, E, M>, prev: T, next: T): boolean;
|
||||
getCenter(this: TLShapeUtil<T, E, M>, shape: T): number[];
|
||||
getRef(this: TLShapeUtil<T, E, M>, shape: T): React.RefObject<E>;
|
||||
getBindingPoint<K extends TLShape>(this: TLShapeUtil<T, E, M>, shape: T, fromShape: K, point: number[], origin: number[], direction: number[], padding: number, bindAnywhere: boolean): {
|
||||
point: number[];
|
||||
distance: number;
|
||||
} | undefined;
|
||||
create: (this: TLShapeUtil<T, E, M>, props: {
|
||||
id: string;
|
||||
} & Partial<T>) => T;
|
||||
mutate: (this: TLShapeUtil<T, E, M>, shape: T, props: Partial<T>) => Partial<T>;
|
||||
transform: (this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds, info: TLTransformInfo<T>) => Partial<T> | void;
|
||||
transformSingle: (this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds, info: TLTransformInfo<T>) => Partial<T> | void;
|
||||
updateChildren: <K extends TLShape>(this: TLShapeUtil<T, E, M>, shape: T, children: K[]) => Partial<K>[] | void;
|
||||
onChildrenChange: (this: TLShapeUtil<T, E, M>, shape: T, children: TLShape[]) => Partial<T> | void;
|
||||
onBindingChange: (this: TLShapeUtil<T, E, M>, shape: T, binding: TLBinding, target: TLShape, targetBounds: TLBounds, center: number[]) => Partial<T> | void;
|
||||
onHandleChange: (this: TLShapeUtil<T, E, M>, shape: T, handle: Partial<T['handles']>, info: Partial<TLPointerInfo>) => Partial<T> | void;
|
||||
onRightPointHandle: (this: TLShapeUtil<T, E, M>, shape: T, handle: Partial<T['handles']>, info: Partial<TLPointerInfo>) => Partial<T> | void;
|
||||
onDoubleClickHandle: (this: TLShapeUtil<T, E, M>, shape: T, handle: Partial<T['handles']>, info: Partial<TLPointerInfo>) => Partial<T> | void;
|
||||
onDoubleClickBoundsHandle: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void;
|
||||
onSessionComplete: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void;
|
||||
onStyleChange: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void;
|
||||
_Component: React.ForwardRefExoticComponent<any>;
|
||||
};
|
||||
export interface IShapeTreeNode<T extends TLShape, M = any> {
|
||||
shape: T;
|
||||
children?: IShapeTreeNode<TLShape, M>[];
|
||||
isEditing: boolean;
|
||||
isBinding: boolean;
|
||||
isHovered: boolean;
|
||||
isSelected: boolean;
|
||||
isCurrentParent: boolean;
|
||||
meta?: M extends any ? M : never;
|
||||
}
|
|
@ -2,9 +2,8 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* --------------------- Primary -------------------- */
|
||||
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import React, { ForwardedRef } from 'react'
|
||||
import { intersectPolylineBounds } from '@tldraw/intersect'
|
||||
import type React from 'react'
|
||||
import type { ForwardedRef } from 'react'
|
||||
|
||||
export type Patch<T> = Partial<{ [P in keyof T]: T | Partial<T> | Patch<T[P]> }>
|
||||
|
||||
|
@ -14,7 +13,6 @@ export interface TLPage<T extends TLShape, B extends TLBinding> {
|
|||
childIndex?: number
|
||||
shapes: Record<string, T>
|
||||
bindings: Record<string, B>
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
export interface TLPageState {
|
||||
|
@ -58,9 +56,15 @@ export interface TLShape {
|
|||
isAspectRatioLocked?: boolean
|
||||
}
|
||||
|
||||
export type TLShapeUtils<T extends TLShape, E extends Element> = Record<string, TLShapeUtil<T, E>>
|
||||
export type TLShapeUtils<
|
||||
T extends TLShape = any,
|
||||
E extends Element = any,
|
||||
M = any,
|
||||
K = any
|
||||
> = Record<string, TLShapeUtil<T, E, M, K>>
|
||||
|
||||
export interface TLRenderInfo<T extends TLShape, M = any, E = any> {
|
||||
export interface TLRenderInfo<T extends TLShape, E = any, M = any> {
|
||||
shape: T
|
||||
isEditing: boolean
|
||||
isBinding: boolean
|
||||
isHovered: boolean
|
||||
|
@ -78,7 +82,7 @@ export interface TLRenderInfo<T extends TLShape, M = any, E = any> {
|
|||
}
|
||||
}
|
||||
|
||||
export interface TLShapeProps<T extends TLShape, E = any, M = any> extends TLRenderInfo<T, M, E> {
|
||||
export interface TLShapeProps<T extends TLShape, E = any, M = any> extends TLRenderInfo<T, E, M> {
|
||||
ref: ForwardedRef<E>
|
||||
shape: T
|
||||
}
|
||||
|
@ -88,11 +92,12 @@ export interface TLTool {
|
|||
name: string
|
||||
}
|
||||
|
||||
export interface TLBinding {
|
||||
export interface TLBinding<M = any> {
|
||||
id: string
|
||||
type: string
|
||||
toId: string
|
||||
fromId: string
|
||||
meta: M
|
||||
}
|
||||
|
||||
export interface TLTheme {
|
||||
|
@ -266,175 +271,140 @@ export interface TLBezierCurveSegment {
|
|||
/* Shape Utility */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
export abstract class TLShapeUtil<T extends TLShape, E extends Element> {
|
||||
refMap = new Map<string, React.RefObject<E>>()
|
||||
export type TLShapeUtil<
|
||||
T extends TLShape,
|
||||
E extends Element,
|
||||
M = any,
|
||||
K = { [key: string]: any }
|
||||
> = K & {
|
||||
type: T['type']
|
||||
|
||||
boundsCache = new WeakMap<TLShape, TLBounds>()
|
||||
defaultProps: T
|
||||
|
||||
isEditableText = false
|
||||
Component(
|
||||
this: TLShapeUtil<T, E, M>,
|
||||
props: TLRenderInfo<T, E, M>,
|
||||
ref: React.ForwardedRef<E>
|
||||
): React.ReactElement<TLRenderInfo<T, E, M>, E['tagName']>
|
||||
|
||||
isAspectRatioLocked = false
|
||||
Indicator(this: TLShapeUtil<T, E, M>, props: { shape: T }): React.ReactElement | null
|
||||
|
||||
canEdit = false
|
||||
getBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds
|
||||
|
||||
canBind = false
|
||||
refMap: Map<string, React.RefObject<E>>
|
||||
|
||||
abstract type: T['type']
|
||||
boundsCache: WeakMap<TLShape, TLBounds>
|
||||
|
||||
abstract defaultProps: T
|
||||
isAspectRatioLocked: boolean
|
||||
|
||||
abstract render: React.ForwardRefExoticComponent<
|
||||
{ shape: T; ref: React.ForwardedRef<E> } & TLRenderInfo<T> & React.RefAttributes<E>
|
||||
>
|
||||
canEdit: boolean
|
||||
|
||||
abstract renderIndicator(shape: T): JSX.Element | null
|
||||
canBind: boolean
|
||||
|
||||
abstract getBounds(shape: T): TLBounds
|
||||
getRotatedBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds
|
||||
|
||||
abstract getRotatedBounds(shape: T): TLBounds
|
||||
hitTest(this: TLShapeUtil<T, E, M>, shape: T, point: number[]): boolean
|
||||
|
||||
shouldRender(_prev: T, _next: T): boolean {
|
||||
return true
|
||||
}
|
||||
hitTestBounds(this: TLShapeUtil<T, E, M>, shape: T, bounds: TLBounds): boolean
|
||||
|
||||
shouldDelete(_shape: T): boolean {
|
||||
return false
|
||||
}
|
||||
shouldRender(this: TLShapeUtil<T, E, M>, prev: T, next: T): boolean
|
||||
|
||||
getCenter(shape: T): number[] {
|
||||
const bounds = this.getBounds(shape)
|
||||
return [bounds.width / 2, bounds.height / 2]
|
||||
}
|
||||
getCenter(this: TLShapeUtil<T, E, M>, shape: T): number[]
|
||||
|
||||
getRef(shape: T): React.RefObject<E> {
|
||||
if (!this.refMap.has(shape.id)) {
|
||||
this.refMap.set(shape.id, React.createRef<E>())
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return this.refMap.get(shape.id)!
|
||||
}
|
||||
getRef(this: TLShapeUtil<T, E, M>, shape: T): React.RefObject<E>
|
||||
|
||||
getBindingPoint(
|
||||
getBindingPoint<K extends TLShape>(
|
||||
this: TLShapeUtil<T, E, M>,
|
||||
shape: T,
|
||||
fromShape: TLShape,
|
||||
fromShape: K,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
padding: number,
|
||||
anywhere: boolean
|
||||
): { point: number[]; distance: number } | undefined {
|
||||
return undefined
|
||||
}
|
||||
bindAnywhere: boolean
|
||||
): { point: number[]; distance: number } | undefined
|
||||
|
||||
create(props: { id: string } & Partial<T>): T {
|
||||
this.refMap.set(props.id, React.createRef<E>())
|
||||
return { ...this.defaultProps, ...props }
|
||||
}
|
||||
create: (this: TLShapeUtil<T, E, M>, props: { id: string } & Partial<T>) => T
|
||||
|
||||
mutate(shape: T, props: Partial<T>): T {
|
||||
return { ...shape, ...props }
|
||||
}
|
||||
mutate: (this: TLShapeUtil<T, E, M>, shape: T, props: Partial<T>) => Partial<T>
|
||||
|
||||
transform(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T> | void {
|
||||
return undefined
|
||||
}
|
||||
transform: (
|
||||
this: TLShapeUtil<T, E, M>,
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
info: TLTransformInfo<T>
|
||||
) => Partial<T> | void
|
||||
|
||||
transformSingle(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T> | void {
|
||||
return this.transform(shape, bounds, info)
|
||||
}
|
||||
transformSingle: (
|
||||
this: TLShapeUtil<T, E, M>,
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
info: TLTransformInfo<T>
|
||||
) => Partial<T> | void
|
||||
|
||||
updateChildren<K extends TLShape>(shape: T, children: K[]): Partial<K>[] | void {
|
||||
return
|
||||
}
|
||||
updateChildren: <K extends TLShape>(
|
||||
this: TLShapeUtil<T, E, M>,
|
||||
shape: T,
|
||||
children: K[]
|
||||
) => Partial<K>[] | void
|
||||
|
||||
onChildrenChange(shape: T, children: TLShape[]): Partial<T> | void {
|
||||
return
|
||||
}
|
||||
onChildrenChange: (this: TLShapeUtil<T, E, M>, shape: T, children: TLShape[]) => Partial<T> | void
|
||||
|
||||
onBindingChange(
|
||||
onBindingChange: (
|
||||
this: TLShapeUtil<T, E, M>,
|
||||
shape: T,
|
||||
binding: TLBinding,
|
||||
target: TLShape,
|
||||
targetBounds: TLBounds,
|
||||
center: number[]
|
||||
): Partial<T> | void {
|
||||
return undefined
|
||||
}
|
||||
) => Partial<T> | void
|
||||
|
||||
onHandleChange(
|
||||
onHandleChange: (
|
||||
this: TLShapeUtil<T, E, M>,
|
||||
shape: T,
|
||||
handle: Partial<T['handles']>,
|
||||
info: Partial<TLPointerInfo>
|
||||
): Partial<T> | void {
|
||||
return
|
||||
}
|
||||
) => Partial<T> | void
|
||||
|
||||
onRightPointHandle(
|
||||
onRightPointHandle: (
|
||||
this: TLShapeUtil<T, E, M>,
|
||||
shape: T,
|
||||
handle: Partial<T['handles']>,
|
||||
info: Partial<TLPointerInfo>
|
||||
): Partial<T> | void {
|
||||
return
|
||||
}
|
||||
) => Partial<T> | void
|
||||
|
||||
onDoubleClickHandle(
|
||||
onDoubleClickHandle: (
|
||||
this: TLShapeUtil<T, E, M>,
|
||||
shape: T,
|
||||
handle: Partial<T['handles']>,
|
||||
info: Partial<TLPointerInfo>
|
||||
): Partial<T> | void {
|
||||
return
|
||||
}
|
||||
) => Partial<T> | void
|
||||
|
||||
onSessionComplete(shape: T): Partial<T> | void {
|
||||
return
|
||||
}
|
||||
onDoubleClickBoundsHandle: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void
|
||||
|
||||
onBoundsReset(shape: T): Partial<T> | void {
|
||||
return
|
||||
}
|
||||
onSessionComplete: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void
|
||||
|
||||
onStyleChange(shape: T): Partial<T> | void {
|
||||
return
|
||||
}
|
||||
onStyleChange: (this: TLShapeUtil<T, E, M>, shape: T) => Partial<T> | void
|
||||
|
||||
hitTest(shape: T, point: number[]) {
|
||||
const bounds = this.getBounds(shape)
|
||||
return !(
|
||||
point[0] < bounds.minX ||
|
||||
point[0] > bounds.maxX ||
|
||||
point[1] < bounds.minY ||
|
||||
point[1] > bounds.maxY
|
||||
)
|
||||
}
|
||||
|
||||
hitTestBounds(shape: T, bounds: TLBounds) {
|
||||
const { minX, minY, maxX, maxY, width, height } = this.getBounds(shape)
|
||||
const center = [minX + width / 2, minY + height / 2]
|
||||
|
||||
const corners = [
|
||||
[minX, minY],
|
||||
[maxX, minY],
|
||||
[maxX, maxY],
|
||||
[minX, maxY],
|
||||
].map((point) => Vec.rotWith(point, center, shape.rotation || 0))
|
||||
|
||||
return (
|
||||
corners.every(
|
||||
(point) =>
|
||||
!(
|
||||
point[0] < bounds.minX ||
|
||||
point[0] > bounds.maxX ||
|
||||
point[1] < bounds.minY ||
|
||||
point[1] > bounds.maxY
|
||||
)
|
||||
) || intersectPolylineBounds(corners, bounds).length > 0
|
||||
)
|
||||
}
|
||||
_Component: React.ForwardRefExoticComponent<any>
|
||||
}
|
||||
|
||||
// export interface TLShapeUtil<T extends TLShape, E extends Element, M = any>
|
||||
// extends TLShapeUtilRequired<T, E, M>,
|
||||
// Required<TLShapeUtilDefaults<T, E>> {
|
||||
// _Component: React.ForwardRefExoticComponent<any> & {
|
||||
// defaultProps: any
|
||||
// propTypes: any
|
||||
// }
|
||||
// }
|
||||
|
||||
// export interface TLShapeUtilConfig<T extends TLShape, E extends Element, M = any>
|
||||
// extends TLShapeUtilRequired<T, E, M>,
|
||||
// Partial<TLShapeUtilDefaults<T, E>> {}
|
||||
|
||||
/* -------------------- Internal -------------------- */
|
||||
|
||||
export interface IShapeTreeNode<T extends TLShape, M extends Record<string, unknown>> {
|
||||
export interface IShapeTreeNode<T extends TLShape, M = any> {
|
||||
shape: T
|
||||
children?: IShapeTreeNode<TLShape, M>[]
|
||||
isEditing: boolean
|
||||
|
@ -449,13 +419,11 @@ export interface IShapeTreeNode<T extends TLShape, M extends Record<string, unkn
|
|||
/* Utility Types */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
export type MappedByType<T extends { type: string }> = {
|
||||
export type MappedByType<K extends string, T extends { type: K }> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[P in T['type']]: T extends any ? (P extends T['type'] ? T : never) : never
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type RequiredKeys<T> = {
|
||||
[K in keyof T]-?: Record<string, unknown> extends Pick<T, K> ? never : K
|
||||
}[keyof T]
|
||||
|
|
|
@ -13,8 +13,14 @@
|
|||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"incremental": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"emitDeclarationOnly": true
|
||||
}
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"paths": {
|
||||
"+*": ["./*"],
|
||||
"@tldraw/vec": ["../vec"],
|
||||
"@tldraw/intersect": ["../intersect"]
|
||||
}
|
||||
},
|
||||
"references": [{ "path": "../vec" }, { "path": "../intersect" }]
|
||||
}
|
||||
|
|
|
@ -8,9 +8,17 @@
|
|||
"baseUrl": "src",
|
||||
"emitDeclarationOnly": false,
|
||||
"paths": {
|
||||
"+*": ["./*"]
|
||||
"+*": ["./*"],
|
||||
"@tldraw/vec": ["../vec"],
|
||||
"@tldraw/intersect": ["../intersect"]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../intersect"
|
||||
},
|
||||
{ "path": "../vec" }
|
||||
],
|
||||
"typedocOptions": {
|
||||
"entryPoints": ["src/index.ts"],
|
||||
"out": "docs"
|
||||
|
|
|
@ -8,9 +8,17 @@
|
|||
"baseUrl": "src",
|
||||
"emitDeclarationOnly": false,
|
||||
"paths": {
|
||||
"+*": ["./*"]
|
||||
"+*": ["./*"],
|
||||
"@tldraw/core": ["../vec"],
|
||||
"@tldraw/tldraw": ["../intersect"]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../tldraw"
|
||||
},
|
||||
{ "path": "../core" }
|
||||
],
|
||||
"typedocOptions": {
|
||||
"entryPoints": ["src/index.ts"],
|
||||
"out": "docs"
|
||||
|
|
|
@ -15,6 +15,10 @@
|
|||
"incremental": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"emitDeclarationOnly": true
|
||||
}
|
||||
"emitDeclarationOnly": true,
|
||||
"paths": {
|
||||
"@tldraw/vec": ["../vec"]
|
||||
}
|
||||
},
|
||||
"references": [{ "path": "../vec" }]
|
||||
}
|
||||
|
|
|
@ -6,8 +6,12 @@
|
|||
"outDir": "./dist/types",
|
||||
"rootDir": "src",
|
||||
"baseUrl": "src",
|
||||
"emitDeclarationOnly": false
|
||||
"emitDeclarationOnly": false,
|
||||
"paths": {
|
||||
"@tldraw/vec": ["../vec"]
|
||||
}
|
||||
},
|
||||
"references": [{ "path": "../vec" }],
|
||||
"typedocOptions": {
|
||||
"entryPoints": ["src/index.ts"],
|
||||
"out": "docs"
|
||||
|
|
|
@ -72,4 +72,4 @@
|
|||
"rko": "^0.5.25"
|
||||
},
|
||||
"gitHead": "5cb031ddc264846ec6732d7179511cddea8ef034"
|
||||
}
|
||||
}
|
|
@ -1,39 +1,18 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Rectangle, Ellipse, Arrow, Draw, Text, Group, PostIt } from './shapes'
|
||||
import { TLDrawShapeType, TLDrawShape, TLDrawShapeUtil, TLDrawShapeUtils } from '~types'
|
||||
import { TLDrawShapeType, TLDrawShape, TLDrawShapeUtil } from '~types'
|
||||
|
||||
export const tldrawShapeUtils: TLDrawShapeUtils = {
|
||||
[TLDrawShapeType.Rectangle]: new Rectangle(),
|
||||
[TLDrawShapeType.Ellipse]: new Ellipse(),
|
||||
[TLDrawShapeType.Draw]: new Draw(),
|
||||
[TLDrawShapeType.Arrow]: new Arrow(),
|
||||
[TLDrawShapeType.Text]: new Text(),
|
||||
[TLDrawShapeType.Group]: new Group(),
|
||||
[TLDrawShapeType.PostIt]: new PostIt(),
|
||||
} as TLDrawShapeUtils
|
||||
|
||||
export type ShapeByType<T extends keyof TLDrawShapeUtils> = TLDrawShapeUtils[T]
|
||||
|
||||
export function getShapeUtilsByType<T extends TLDrawShape>(
|
||||
shape: T
|
||||
): TLDrawShapeUtil<T, HTMLElement | SVGElement> {
|
||||
return tldrawShapeUtils[shape.type as T['type']] as unknown as TLDrawShapeUtil<
|
||||
T,
|
||||
HTMLElement | SVGElement
|
||||
>
|
||||
// This is a bad "any", but the "this" context stuff we're doing doesn't allow us to union the types
|
||||
export const tldrawShapeUtils: Record<TLDrawShapeType, any> = {
|
||||
[TLDrawShapeType.Rectangle]: Rectangle,
|
||||
[TLDrawShapeType.Ellipse]: Ellipse,
|
||||
[TLDrawShapeType.Draw]: Draw,
|
||||
[TLDrawShapeType.Arrow]: Arrow,
|
||||
[TLDrawShapeType.Text]: Text,
|
||||
[TLDrawShapeType.Group]: Group,
|
||||
[TLDrawShapeType.PostIt]: PostIt,
|
||||
}
|
||||
|
||||
export function getShapeUtils<T extends TLDrawShape>(
|
||||
shape: T
|
||||
): TLDrawShapeUtil<T, HTMLElement | SVGElement> {
|
||||
return tldrawShapeUtils[shape.type as T['type']] as unknown as TLDrawShapeUtil<
|
||||
T,
|
||||
HTMLElement | SVGElement
|
||||
>
|
||||
}
|
||||
|
||||
export function createShape<TLDrawShape>(
|
||||
type: TLDrawShapeType,
|
||||
props: { id: string } & Partial<TLDrawShape>
|
||||
) {
|
||||
return tldrawShapeUtils[type].create(props)
|
||||
export function getShapeUtils<T extends TLDrawShape>(type: T['type']) {
|
||||
return tldrawShapeUtils[type] as TLDrawShapeUtil<T>
|
||||
}
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Arrow shape Creates a shape: arrow 1`] = `
|
||||
Object {
|
||||
"bend": 0,
|
||||
"childIndex": 1,
|
||||
"decorations": Object {
|
||||
"end": "Arrow",
|
||||
},
|
||||
"handles": Object {
|
||||
"bend": Object {
|
||||
"id": "bend",
|
||||
"index": 2,
|
||||
"point": Array [
|
||||
0.5,
|
||||
0.5,
|
||||
],
|
||||
},
|
||||
"end": Object {
|
||||
"canBind": true,
|
||||
"id": "end",
|
||||
"index": 1,
|
||||
"point": Array [
|
||||
1,
|
||||
1,
|
||||
],
|
||||
},
|
||||
"start": Object {
|
||||
"canBind": true,
|
||||
"id": "start",
|
||||
"index": 0,
|
||||
"point": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
},
|
||||
},
|
||||
"id": "arrow",
|
||||
"name": "Arrow",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"rotation": 0,
|
||||
"style": Object {
|
||||
"color": "Black",
|
||||
"dash": "Draw",
|
||||
"isFilled": false,
|
||||
"size": "Medium",
|
||||
},
|
||||
"type": "arrow",
|
||||
}
|
||||
`;
|
|
@ -1,7 +1,7 @@
|
|||
import { Arrow } from './arrow'
|
||||
|
||||
describe('Arrow shape', () => {
|
||||
it('Creates an instance', () => {
|
||||
new Arrow()
|
||||
it('Creates a shape', () => {
|
||||
expect(Arrow.create({ id: 'arrow' })).toMatchSnapshot('arrow')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,26 +1,17 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
SVGContainer,
|
||||
TLBounds,
|
||||
Utils,
|
||||
TLTransformInfo,
|
||||
TLHandle,
|
||||
TLPointerInfo,
|
||||
TLShapeProps,
|
||||
} from '@tldraw/core'
|
||||
import { ShapeUtil, SVGContainer, TLBounds, Utils, TLHandle } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
|
||||
import getStroke from 'perfect-freehand'
|
||||
import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles'
|
||||
import {
|
||||
ArrowShape,
|
||||
Decoration,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawShapeType,
|
||||
TLDrawToolType,
|
||||
DashStyle,
|
||||
TLDrawShape,
|
||||
ArrowBinding,
|
||||
TLDrawMeta,
|
||||
EllipseShape,
|
||||
} from '~types'
|
||||
import {
|
||||
intersectArcBounds,
|
||||
|
@ -31,16 +22,20 @@ import {
|
|||
intersectRayEllipse,
|
||||
} from '@tldraw/intersect'
|
||||
|
||||
export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Arrow as const
|
||||
toolType = TLDrawToolType.Handle
|
||||
canStyleFill = false
|
||||
simplePathCache = new WeakMap<ArrowShape['handles'], string>()
|
||||
pathCache = new WeakMap<ArrowShape, string>()
|
||||
const simplePathCache = new WeakMap<ArrowShape['handles'], string>()
|
||||
|
||||
defaultProps = {
|
||||
export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||
type: TLDrawShapeType.Arrow,
|
||||
|
||||
toolType: TLDrawToolType.Handle,
|
||||
|
||||
canStyleFill: false,
|
||||
|
||||
pathCache: new WeakMap<ArrowShape, string>(),
|
||||
|
||||
defaultProps: {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.Arrow as const,
|
||||
type: TLDrawShapeType.Arrow,
|
||||
name: 'Arrow',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
|
@ -73,136 +68,62 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
...defaultStyle,
|
||||
isFilled: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
shouldRender = (prev: ArrowShape, next: ArrowShape) => {
|
||||
return next.handles !== prev.handles || next.style !== prev.style
|
||||
}
|
||||
Component({ shape, meta, events }, ref) {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
decorations = {},
|
||||
style,
|
||||
} = shape
|
||||
|
||||
render = React.forwardRef<SVGSVGElement, TLShapeProps<ArrowShape, SVGSVGElement>>(
|
||||
({ shape, meta, events }, ref) => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
decorations = {},
|
||||
style,
|
||||
} = shape
|
||||
const isDraw = style.dash === DashStyle.Draw
|
||||
|
||||
const isDraw = style.dash === DashStyle.Draw
|
||||
// TODO: Improve drawn arrows
|
||||
|
||||
// TODO: Improve drawn arrows
|
||||
const isStraightLine = Vec.dist(bend.point, Vec.round(Vec.med(start.point, end.point))) < 1
|
||||
|
||||
const isStraightLine = Vec.dist(bend.point, Vec.round(Vec.med(start.point, end.point))) < 1
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const { strokeWidth } = styles
|
||||
|
||||
const { strokeWidth } = styles
|
||||
const arrowDist = Vec.dist(start.point, end.point)
|
||||
|
||||
const arrowDist = Vec.dist(start.point, end.point)
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
|
||||
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
|
||||
let shaftPath: JSX.Element | null
|
||||
let startArrowHead: { left: number[]; right: number[] } | undefined
|
||||
let endArrowHead: { left: number[]; right: number[] } | undefined
|
||||
|
||||
let shaftPath: JSX.Element | null
|
||||
let startArrowHead: { left: number[]; right: number[] } | undefined
|
||||
let endArrowHead: { left: number[]; right: number[] } | undefined
|
||||
if (isStraightLine) {
|
||||
const sw = strokeWidth * (isDraw ? 1.25 : 1.618)
|
||||
|
||||
if (isStraightLine) {
|
||||
const sw = strokeWidth * (isDraw ? 1.25 : 1.618)
|
||||
const path = isDraw
|
||||
? renderFreehandArrowShaft(shape)
|
||||
: 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point)
|
||||
|
||||
const path = Utils.getFromCache(this.pathCache, shape, () =>
|
||||
isDraw
|
||||
? renderFreehandArrowShaft(shape)
|
||||
: 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point)
|
||||
)
|
||||
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
|
||||
arrowDist,
|
||||
sw,
|
||||
shape.style.dash,
|
||||
2
|
||||
)
|
||||
|
||||
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
|
||||
arrowDist,
|
||||
sw,
|
||||
shape.style.dash,
|
||||
2
|
||||
)
|
||||
if (decorations.start) {
|
||||
startArrowHead = getStraightArrowHeadPoints(start.point, end.point, arrowHeadLength)
|
||||
}
|
||||
|
||||
if (decorations.start) {
|
||||
startArrowHead = getStraightArrowHeadPoints(start.point, end.point, arrowHeadLength)
|
||||
}
|
||||
if (decorations.end) {
|
||||
endArrowHead = getStraightArrowHeadPoints(end.point, start.point, arrowHeadLength)
|
||||
}
|
||||
|
||||
if (decorations.end) {
|
||||
endArrowHead = getStraightArrowHeadPoints(end.point, start.point, arrowHeadLength)
|
||||
}
|
||||
|
||||
// Straight arrow path
|
||||
shaftPath =
|
||||
arrowDist > 2 ? (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
strokeWidth={Math.max(8, strokeWidth * 2)}
|
||||
strokeDasharray="none"
|
||||
strokeDashoffset="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
} else {
|
||||
const circle = getCtp(shape)
|
||||
|
||||
const sw = strokeWidth * (isDraw ? 1.25 : 1.618)
|
||||
|
||||
const path = Utils.getFromCache(this.pathCache, shape, () =>
|
||||
isDraw
|
||||
? renderCurvedFreehandArrowShaft(shape, circle)
|
||||
: getArrowArcPath(start, end, circle, shape.bend)
|
||||
)
|
||||
|
||||
const { center, radius, length } = getArrowArc(shape)
|
||||
|
||||
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
|
||||
length - 1,
|
||||
sw,
|
||||
shape.style.dash,
|
||||
2
|
||||
)
|
||||
|
||||
if (decorations.start) {
|
||||
startArrowHead = getCurvedArrowHeadPoints(
|
||||
start.point,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length < 0
|
||||
)
|
||||
}
|
||||
|
||||
if (decorations.end) {
|
||||
endArrowHead = getCurvedArrowHeadPoints(
|
||||
end.point,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length >= 0
|
||||
)
|
||||
}
|
||||
|
||||
// Curved arrow path
|
||||
shaftPath = (
|
||||
// Straight arrow path
|
||||
shaftPath =
|
||||
arrowDist > 2 ? (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={Math.max(8, strokeWidth * 2)}
|
||||
strokeDasharray="none"
|
||||
strokeDashoffset="none"
|
||||
|
@ -212,7 +133,7 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill={isDraw ? styles.stroke : 'none'}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
|
@ -222,81 +143,145 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
pointerEvents="stroke"
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
} else {
|
||||
const circle = getCtp(shape)
|
||||
|
||||
const sw = strokeWidth * (isDraw ? 1.25 : 1.618)
|
||||
|
||||
const path = isDraw
|
||||
? renderCurvedFreehandArrowShaft(shape, circle)
|
||||
: getArrowArcPath(start, end, circle, shape.bend)
|
||||
|
||||
const { center, radius, length } = getArrowArc(shape)
|
||||
|
||||
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
|
||||
length - 1,
|
||||
sw,
|
||||
shape.style.dash,
|
||||
2
|
||||
)
|
||||
|
||||
if (decorations.start) {
|
||||
startArrowHead = getCurvedArrowHeadPoints(
|
||||
start.point,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length < 0
|
||||
)
|
||||
}
|
||||
|
||||
const sw = strokeWidth * 1.618
|
||||
if (decorations.end) {
|
||||
endArrowHead = getCurvedArrowHeadPoints(
|
||||
end.point,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length >= 0
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
<g pointerEvents="none">
|
||||
{shaftPath}
|
||||
{startArrowHead && (
|
||||
<path
|
||||
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
|
||||
fill="none"
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDashoffset="none"
|
||||
strokeDasharray="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
)}
|
||||
{endArrowHead && (
|
||||
<path
|
||||
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
|
||||
fill="none"
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDashoffset="none"
|
||||
strokeDasharray="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</SVGContainer>
|
||||
// Curved arrow path
|
||||
shaftPath = (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={Math.max(8, strokeWidth * 2)}
|
||||
strokeDasharray="none"
|
||||
strokeDashoffset="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill={isDraw ? styles.stroke : 'none'}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
renderIndicator(shape: ArrowShape) {
|
||||
const path = Utils.getFromCache(this.simplePathCache, shape.handles, () => getArrowPath(shape))
|
||||
const sw = strokeWidth * 1.618
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
<g pointerEvents="none">
|
||||
{shaftPath}
|
||||
{startArrowHead && (
|
||||
<path
|
||||
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
|
||||
fill="none"
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDashoffset="none"
|
||||
strokeDasharray="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
)}
|
||||
{endArrowHead && (
|
||||
<path
|
||||
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
|
||||
fill="none"
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDashoffset="none"
|
||||
strokeDasharray="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</SVGContainer>
|
||||
)
|
||||
},
|
||||
|
||||
Indicator({ shape }) {
|
||||
const path = Utils.getFromCache(simplePathCache, shape.handles, () => getArrowPath(shape))
|
||||
|
||||
return <path d={path} />
|
||||
}
|
||||
},
|
||||
|
||||
getBounds = (shape: ArrowShape) => {
|
||||
shouldRender(prev, next) {
|
||||
return next.handles !== prev.handles || next.style !== prev.style
|
||||
},
|
||||
|
||||
getBounds(shape) {
|
||||
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
const { start, bend, end } = shape.handles
|
||||
return Utils.getBoundsFromPoints([start.point, bend.point, end.point])
|
||||
})
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
}
|
||||
},
|
||||
|
||||
getRotatedBounds = (shape: ArrowShape) => {
|
||||
getRotatedBounds(shape) {
|
||||
const { start, bend, end } = shape.handles
|
||||
|
||||
return Utils.translateBounds(
|
||||
Utils.getBoundsFromPoints([start.point, bend.point, end.point], shape.rotation),
|
||||
shape.point
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
getCenter = (shape: ArrowShape) => {
|
||||
getCenter(shape) {
|
||||
const { start, end } = shape.handles
|
||||
return Vec.add(shape.point, Vec.med(start.point, end.point))
|
||||
}
|
||||
},
|
||||
|
||||
hitTest = () => {
|
||||
return true
|
||||
}
|
||||
|
||||
hitTestBounds = (shape: ArrowShape, brushBounds: TLBounds) => {
|
||||
hitTestBounds(shape, brushBounds: TLBounds) {
|
||||
const { start, end, bend } = shape.handles
|
||||
|
||||
const sp = Vec.add(shape.point, start.point)
|
||||
|
@ -314,13 +299,9 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
|
||||
return intersectArcBounds(cp, r, sp, ep, brushBounds).length > 0
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transform = (
|
||||
_shape: ArrowShape,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, scaleX, scaleY }: TLTransformInfo<ArrowShape>
|
||||
): Partial<ArrowShape> => {
|
||||
transform(_shape, bounds, { initialShape, scaleX, scaleY }) {
|
||||
const initialShapeBounds = this.getBounds(initialShape)
|
||||
|
||||
const handles: (keyof ArrowShape['handles'])[] = ['start', 'end']
|
||||
|
@ -362,9 +343,9 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
point: [bounds.minX, bounds.minY],
|
||||
handles: nextHandles,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDoubleClickHandle = (shape: ArrowShape, handle: Partial<ArrowShape['handles']>) => {
|
||||
onDoubleClickHandle(shape, handle) {
|
||||
switch (handle) {
|
||||
case 'bend': {
|
||||
return {
|
||||
|
@ -397,16 +378,10 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
}
|
||||
|
||||
return this
|
||||
}
|
||||
},
|
||||
|
||||
onBindingChange = (
|
||||
shape: ArrowShape,
|
||||
binding: ArrowBinding,
|
||||
target: TLDrawShape,
|
||||
targetBounds: TLBounds,
|
||||
center: number[]
|
||||
): void | Partial<ArrowShape> => {
|
||||
const handle = shape.handles[binding.handleId]
|
||||
onBindingChange(shape, binding: ArrowBinding, target, targetBounds, center) {
|
||||
const handle = shape.handles[binding.meta.handleId as keyof ArrowShape['handles']]
|
||||
const expandedBounds = Utils.expandBounds(targetBounds, 32)
|
||||
|
||||
// The anchor is the "actual" point in the target shape
|
||||
|
@ -416,7 +391,7 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
[expandedBounds.minX, expandedBounds.minY],
|
||||
Vec.mulV(
|
||||
[expandedBounds.width, expandedBounds.height],
|
||||
Vec.rotWith(binding.point, [0.5, 0.5], target.rotation || 0)
|
||||
Vec.rotWith(binding.meta.point, [0.5, 0.5], target.rotation || 0)
|
||||
)
|
||||
),
|
||||
shape.point
|
||||
|
@ -425,8 +400,8 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
// We're looking for the point to put the dragging handle
|
||||
let handlePoint = anchor
|
||||
|
||||
if (binding.distance) {
|
||||
const intersectBounds = Utils.expandBounds(targetBounds, binding.distance)
|
||||
if (binding.meta.distance) {
|
||||
const intersectBounds = Utils.expandBounds(targetBounds, binding.meta.distance)
|
||||
|
||||
// The direction vector starts from the arrow's opposite handle
|
||||
const origin = Vec.add(
|
||||
|
@ -437,7 +412,9 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
// And passes through the dragging handle
|
||||
const direction = Vec.uni(Vec.sub(Vec.add(anchor, shape.point), origin))
|
||||
|
||||
if ([TLDrawShapeType.Rectangle, TLDrawShapeType.Text].includes(target.type)) {
|
||||
if (
|
||||
[TLDrawShapeType.Rectangle, TLDrawShapeType.Text].includes(target.type as TLDrawShapeType)
|
||||
) {
|
||||
let hits = intersectRayBounds(origin, direction, intersectBounds, target.rotation)
|
||||
.filter((int) => int.didIntersect)
|
||||
.map((int) => int.points[0])
|
||||
|
@ -461,8 +438,8 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
origin,
|
||||
direction,
|
||||
center,
|
||||
target.radius[0] + binding.distance,
|
||||
target.radius[1] + binding.distance,
|
||||
(target as EllipseShape).radius[0] + binding.meta.distance,
|
||||
(target as EllipseShape).radius[1] + binding.meta.distance,
|
||||
target.rotation || 0
|
||||
).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
|
||||
|
||||
|
@ -484,13 +461,9 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
},
|
||||
{ shiftKey: false }
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
onHandleChange = (
|
||||
shape: ArrowShape,
|
||||
handles: Partial<ArrowShape['handles']>,
|
||||
{ shiftKey }: Partial<TLPointerInfo>
|
||||
) => {
|
||||
onHandleChange(shape, handles, { shiftKey }) {
|
||||
let nextHandles = Utils.deepMerge<ArrowShape['handles']>(shape.handles, handles)
|
||||
let nextBend = shape.bend
|
||||
|
||||
|
@ -586,8 +559,12 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
|||
}
|
||||
|
||||
return nextShape
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
function getArrowArcPath(start: TLHandle, end: TLHandle, circle: number[], bend: number) {
|
||||
return [
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Draw shape Creates a shape: draw 1`] = `
|
||||
Object {
|
||||
"childIndex": 1,
|
||||
"id": "draw",
|
||||
"name": "Draw",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"points": Array [],
|
||||
"rotation": 0,
|
||||
"style": Object {
|
||||
"color": "Black",
|
||||
"dash": "Draw",
|
||||
"isFilled": false,
|
||||
"size": "Medium",
|
||||
},
|
||||
"type": "draw",
|
||||
}
|
||||
`;
|
|
@ -1,7 +1,7 @@
|
|||
import { Draw } from './draw'
|
||||
|
||||
describe('Draw shape', () => {
|
||||
it('Creates an instance', () => {
|
||||
new Draw()
|
||||
it('Creates a shape', () => {
|
||||
expect(Draw.create({ id: 'draw' })).toMatchSnapshot('draw')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,31 +1,25 @@
|
|||
import * as React from 'react'
|
||||
import { SVGContainer, TLBounds, Utils, TLTransformInfo } from '@tldraw/core'
|
||||
import { SVGContainer, TLBounds, Utils, TLTransformInfo, ShapeUtil } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import { intersectBoundsBounds, intersectBoundsPolyline } from '@tldraw/intersect'
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand'
|
||||
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
|
||||
import {
|
||||
DrawShape,
|
||||
DashStyle,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawShapeType,
|
||||
TLDrawToolType,
|
||||
TLDrawShapeProps,
|
||||
} from '~types'
|
||||
import { DrawShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
|
||||
|
||||
export class Draw extends TLDrawShapeUtil<DrawShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Draw as const
|
||||
toolType = TLDrawToolType.Draw
|
||||
const pointsBoundsCache = new WeakMap<DrawShape['points'], TLBounds>([])
|
||||
const rotatedCache = new WeakMap<DrawShape, number[][]>([])
|
||||
const drawPathCache = new WeakMap<DrawShape['points'], string>([])
|
||||
const simplePathCache = new WeakMap<DrawShape['points'], string>([])
|
||||
const polygonCache = new WeakMap<DrawShape['points'], string>([])
|
||||
|
||||
pointsBoundsCache = new WeakMap<DrawShape['points'], TLBounds>([])
|
||||
rotatedCache = new WeakMap<DrawShape, number[][]>([])
|
||||
drawPathCache = new WeakMap<DrawShape['points'], string>([])
|
||||
simplePathCache = new WeakMap<DrawShape['points'], string>([])
|
||||
polygonCache = new WeakMap<DrawShape['points'], string>([])
|
||||
export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||
type: TLDrawShapeType.Draw,
|
||||
|
||||
defaultProps: DrawShape = {
|
||||
toolType: TLDrawToolType.Draw,
|
||||
|
||||
defaultProps: {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.Draw as const,
|
||||
type: TLDrawShapeType.Draw,
|
||||
name: 'Draw',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
|
@ -33,130 +27,122 @@ export class Draw extends TLDrawShapeUtil<DrawShape, SVGSVGElement> {
|
|||
points: [],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
}
|
||||
},
|
||||
|
||||
shouldRender(prev: DrawShape, next: DrawShape): boolean {
|
||||
return next.points !== prev.points || next.style !== prev.style
|
||||
}
|
||||
Component({ shape, meta, events, isEditing }, ref) {
|
||||
const { points, style } = shape
|
||||
|
||||
render = React.forwardRef<SVGSVGElement, TLDrawShapeProps<DrawShape, SVGSVGElement>>(
|
||||
({ shape, meta, events, isEditing }, ref) => {
|
||||
const { points, style } = shape
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const strokeWidth = styles.strokeWidth
|
||||
|
||||
const strokeWidth = styles.strokeWidth
|
||||
// For very short lines, draw a point instead of a line
|
||||
const bounds = this.getBounds(shape)
|
||||
|
||||
// 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
|
||||
|
||||
const verySmall = bounds.width < strokeWidth / 2 && bounds.height < strokeWidth / 2
|
||||
|
||||
if (!isEditing && verySmall) {
|
||||
const sw = strokeWidth * 0.618
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
<circle
|
||||
r={strokeWidth * 0.618}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const shouldFill =
|
||||
style.isFilled &&
|
||||
points.length > 3 &&
|
||||
Vec.dist(points[0], points[points.length - 1]) < +styles.strokeWidth * 2
|
||||
|
||||
// For drawn lines, draw a line from the path cache
|
||||
|
||||
if (shape.style.dash === DashStyle.Draw) {
|
||||
const polygonPathData = Utils.getFromCache(this.polygonCache, points, () =>
|
||||
getFillPath(shape)
|
||||
)
|
||||
|
||||
const drawPathData = isEditing
|
||||
? getDrawStrokePath(shape, true)
|
||||
: Utils.getFromCache(this.drawPathCache, points, () => getDrawStrokePath(shape, false))
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{shouldFill && (
|
||||
<path
|
||||
d={polygonPathData}
|
||||
stroke="none"
|
||||
fill={styles.fill}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="fill"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={drawPathData}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="all"
|
||||
/>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// For solid, dash and dotted lines, draw a regular stroke path
|
||||
|
||||
const strokeDasharray = {
|
||||
[DashStyle.Draw]: 'none',
|
||||
[DashStyle.Solid]: `none`,
|
||||
[DashStyle.Dotted]: `${strokeWidth / 10} ${strokeWidth * 3}`,
|
||||
[DashStyle.Dashed]: `${strokeWidth * 3} ${strokeWidth * 3}`,
|
||||
}[style.dash]
|
||||
|
||||
const strokeDashoffset = {
|
||||
[DashStyle.Draw]: 'none',
|
||||
[DashStyle.Solid]: `none`,
|
||||
[DashStyle.Dotted]: `-${strokeWidth / 20}`,
|
||||
[DashStyle.Dashed]: `-${strokeWidth}`,
|
||||
}[style.dash]
|
||||
|
||||
const path = Utils.getFromCache(this.simplePathCache, points, () => getSolidStrokePath(shape))
|
||||
|
||||
const sw = strokeWidth * 1.618
|
||||
if (!isEditing && verySmall) {
|
||||
const sw = strokeWidth * 0.618
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
<path
|
||||
d={path}
|
||||
fill={shouldFill ? styles.fill : 'none'}
|
||||
stroke="transparent"
|
||||
strokeWidth={Math.min(4, strokeWidth * 2)}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents={shouldFill ? 'all' : 'stroke'}
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill="transparent"
|
||||
<circle
|
||||
r={strokeWidth * 0.618}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="stroke"
|
||||
pointerEvents="all"
|
||||
/>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
renderIndicator(shape: DrawShape): JSX.Element {
|
||||
const shouldFill =
|
||||
style.isFilled &&
|
||||
points.length > 3 &&
|
||||
Vec.dist(points[0], points[points.length - 1]) < +styles.strokeWidth * 2
|
||||
|
||||
// For drawn lines, draw a line from the path cache
|
||||
|
||||
if (shape.style.dash === DashStyle.Draw) {
|
||||
const polygonPathData = Utils.getFromCache(polygonCache, points, () => getFillPath(shape))
|
||||
|
||||
const drawPathData = isEditing
|
||||
? getDrawStrokePath(shape, true)
|
||||
: Utils.getFromCache(drawPathCache, points, () => getDrawStrokePath(shape, false))
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{shouldFill && (
|
||||
<path
|
||||
d={polygonPathData}
|
||||
stroke="none"
|
||||
fill={styles.fill}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="fill"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={drawPathData}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="all"
|
||||
/>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// For solid, dash and dotted lines, draw a regular stroke path
|
||||
|
||||
const strokeDasharray = {
|
||||
[DashStyle.Draw]: 'none',
|
||||
[DashStyle.Solid]: `none`,
|
||||
[DashStyle.Dotted]: `${strokeWidth / 10} ${strokeWidth * 3}`,
|
||||
[DashStyle.Dashed]: `${strokeWidth * 3} ${strokeWidth * 3}`,
|
||||
}[style.dash]
|
||||
|
||||
const strokeDashoffset = {
|
||||
[DashStyle.Draw]: 'none',
|
||||
[DashStyle.Solid]: `none`,
|
||||
[DashStyle.Dotted]: `-${strokeWidth / 20}`,
|
||||
[DashStyle.Dashed]: `-${strokeWidth}`,
|
||||
}[style.dash]
|
||||
|
||||
const path = Utils.getFromCache(simplePathCache, points, () => getSolidStrokePath(shape))
|
||||
|
||||
const sw = strokeWidth * 1.618
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
<path
|
||||
d={path}
|
||||
fill={shouldFill ? styles.fill : 'none'}
|
||||
stroke="transparent"
|
||||
strokeWidth={Math.min(4, strokeWidth * 2)}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents={shouldFill ? 'all' : 'stroke'}
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill="transparent"
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
</SVGContainer>
|
||||
)
|
||||
},
|
||||
|
||||
Indicator({ shape }) {
|
||||
const { points } = shape
|
||||
|
||||
const bounds = this.getBounds(shape)
|
||||
|
@ -167,34 +153,23 @@ export class Draw extends TLDrawShapeUtil<DrawShape, SVGSVGElement> {
|
|||
return <circle x={bounds.width / 2} y={bounds.height / 2} r={1} />
|
||||
}
|
||||
|
||||
const path = Utils.getFromCache(this.simplePathCache, points, () => getSolidStrokePath(shape))
|
||||
const path = Utils.getFromCache(simplePathCache, points, () => getSolidStrokePath(shape))
|
||||
|
||||
return <path d={path} />
|
||||
}
|
||||
},
|
||||
|
||||
getBounds(shape: DrawShape): TLBounds {
|
||||
return Utils.translateBounds(
|
||||
Utils.getFromCache(this.pointsBoundsCache, shape.points, () =>
|
||||
Utils.getFromCache(pointsBoundsCache, shape.points, () =>
|
||||
Utils.getBoundsFromPoints(shape.points)
|
||||
),
|
||||
shape.point
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
getRotatedBounds(shape: DrawShape): TLBounds {
|
||||
return Utils.translateBounds(
|
||||
Utils.getBoundsFromPoints(shape.points, shape.rotation),
|
||||
shape.point
|
||||
)
|
||||
}
|
||||
|
||||
getCenter(shape: DrawShape): number[] {
|
||||
return Utils.getBoundsCenter(this.getBounds(shape))
|
||||
}
|
||||
|
||||
hitTest(): boolean {
|
||||
return true
|
||||
}
|
||||
shouldRender(prev: DrawShape, next: DrawShape): boolean {
|
||||
return next.points !== prev.points || next.style !== prev.style
|
||||
},
|
||||
|
||||
hitTestBounds(shape: DrawShape, brushBounds: TLBounds): boolean {
|
||||
// Test axis-aligned shape
|
||||
|
@ -215,7 +190,7 @@ export class Draw extends TLDrawShapeUtil<DrawShape, SVGSVGElement> {
|
|||
// Test rotated shape
|
||||
const rBounds = this.getRotatedBounds(shape)
|
||||
|
||||
const rotatedBounds = Utils.getFromCache(this.rotatedCache, shape, () => {
|
||||
const rotatedBounds = Utils.getFromCache(rotatedCache, shape, () => {
|
||||
const c = Utils.getBoundsCenter(Utils.getBoundsFromPoints(shape.points))
|
||||
return shape.points.map((pt) => Vec.rotWith(pt, c, shape.rotation || 0))
|
||||
})
|
||||
|
@ -227,7 +202,7 @@ export class Draw extends TLDrawShapeUtil<DrawShape, SVGSVGElement> {
|
|||
rotatedBounds
|
||||
).length > 0
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
transform(
|
||||
shape: DrawShape,
|
||||
|
@ -260,16 +235,12 @@ export class Draw extends TLDrawShapeUtil<DrawShape, SVGSVGElement> {
|
|||
points,
|
||||
point,
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
transformSingle(
|
||||
shape: DrawShape,
|
||||
bounds: TLBounds,
|
||||
info: TLTransformInfo<DrawShape>
|
||||
): Partial<DrawShape> {
|
||||
return this.transform(shape, bounds, info)
|
||||
}
|
||||
}
|
||||
/* -------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
const simulatePressureSettings = {
|
||||
simulatePressure: true,
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Ellipse shape Creates a shape: ellipse 1`] = `
|
||||
Object {
|
||||
"childIndex": 1,
|
||||
"id": "ellipse",
|
||||
"name": "Ellipse",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"radius": Array [
|
||||
1,
|
||||
1,
|
||||
],
|
||||
"rotation": 0,
|
||||
"style": Object {
|
||||
"color": "Black",
|
||||
"dash": "Draw",
|
||||
"isFilled": false,
|
||||
"size": "Medium",
|
||||
},
|
||||
"type": "ellipse",
|
||||
}
|
||||
`;
|
|
@ -1,7 +1,7 @@
|
|||
import { Ellipse } from './ellipse'
|
||||
|
||||
describe('Ellipse shape', () => {
|
||||
it('Creates an instance', () => {
|
||||
new Ellipse()
|
||||
it('Creates a shape', () => {
|
||||
expect(Ellipse.create({ id: 'ellipse' })).toMatchSnapshot('ellipse')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,34 +1,27 @@
|
|||
import * as React from 'react'
|
||||
import { SVGContainer, Utils, TLTransformInfo, TLBounds, TLShapeProps } from '@tldraw/core'
|
||||
import { SVGContainer, Utils, ShapeUtil, TLTransformInfo, TLBounds } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import {
|
||||
ArrowShape,
|
||||
DashStyle,
|
||||
EllipseShape,
|
||||
TLDrawShapeType,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawToolType,
|
||||
} from '~types'
|
||||
import { DashStyle, EllipseShape, TLDrawShapeType, TLDrawMeta, TLDrawToolType } from '~types'
|
||||
import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles'
|
||||
import getStroke from 'perfect-freehand'
|
||||
import {
|
||||
intersectBoundsEllipse,
|
||||
intersectLineSegmentEllipse,
|
||||
intersectPolylineBounds,
|
||||
intersectRayEllipse,
|
||||
} from '@tldraw/intersect'
|
||||
|
||||
// TODO
|
||||
// [ ] Improve indicator shape for drawn shapes
|
||||
export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||
type: TLDrawShapeType.Ellipse,
|
||||
|
||||
export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Ellipse as const
|
||||
toolType = TLDrawToolType.Bounds
|
||||
pathCache = new WeakMap<EllipseShape, string>([])
|
||||
canBind = true
|
||||
toolType: TLDrawToolType.Bounds,
|
||||
|
||||
defaultProps = {
|
||||
pathCache: new WeakMap<EllipseShape, string>([]),
|
||||
|
||||
canBind: true,
|
||||
|
||||
defaultProps: {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.Ellipse as const,
|
||||
type: TLDrawShapeType.Ellipse,
|
||||
name: 'Ellipse',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
|
@ -36,75 +29,22 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
|||
radius: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
}
|
||||
},
|
||||
|
||||
shouldRender(prev: EllipseShape, next: EllipseShape) {
|
||||
return next.radius !== prev.radius || next.style !== prev.style
|
||||
}
|
||||
Component({ shape, meta, isBinding, events }, ref) {
|
||||
const {
|
||||
radius: [radiusX, radiusY],
|
||||
style,
|
||||
} = shape
|
||||
|
||||
render = React.forwardRef<SVGSVGElement, TLShapeProps<EllipseShape, SVGSVGElement>>(
|
||||
({ shape, meta, isBinding, events }, ref) => {
|
||||
const {
|
||||
radius: [radiusX, radiusY],
|
||||
style,
|
||||
} = shape
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const strokeWidth = +styles.strokeWidth
|
||||
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const strokeWidth = +styles.strokeWidth
|
||||
const rx = Math.max(0, radiusX - strokeWidth / 2)
|
||||
const ry = Math.max(0, radiusY - strokeWidth / 2)
|
||||
|
||||
const rx = Math.max(0, radiusX - strokeWidth / 2)
|
||||
const ry = Math.max(0, radiusY - strokeWidth / 2)
|
||||
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
const path = Utils.getFromCache(this.pathCache, shape, () =>
|
||||
renderPath(shape, this.getCenter(shape))
|
||||
)
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<ellipse
|
||||
className="tl-binding-indicator"
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={rx + 2}
|
||||
ry={ry + 2}
|
||||
/>
|
||||
)}
|
||||
<ellipse
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
stroke="none"
|
||||
fill={style.isFilled ? styles.fill : 'none'}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={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 } = getPerfectDashProps(
|
||||
perimeter,
|
||||
strokeWidth * 1.618,
|
||||
shape.style.dash,
|
||||
4
|
||||
)
|
||||
|
||||
const sw = strokeWidth * 1.618
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
const path = renderPath(shape, this.getCenter(shape))
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
|
@ -113,8 +53,8 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
|||
className="tl-binding-indicator"
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={rx + 32}
|
||||
ry={ry + 32}
|
||||
rx={rx + 2}
|
||||
ry={ry + 2}
|
||||
/>
|
||||
)}
|
||||
<ellipse
|
||||
|
@ -122,11 +62,15 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
|||
cy={radiusY}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
fill={styles.fill}
|
||||
stroke="none"
|
||||
fill={style.isFilled ? styles.fill : 'none'}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="all"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
@ -134,9 +78,50 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
|||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
renderIndicator(shape: EllipseShape) {
|
||||
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 } = getPerfectDashProps(
|
||||
perimeter,
|
||||
strokeWidth * 1.618,
|
||||
shape.style.dash,
|
||||
4
|
||||
)
|
||||
|
||||
const sw = strokeWidth * 1.618
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...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({ shape }) {
|
||||
const {
|
||||
style,
|
||||
radius: [rx, ry],
|
||||
|
@ -147,10 +132,16 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
|||
|
||||
const sw = strokeWidth
|
||||
|
||||
// TODO Improve indicator shape for drawn shapes, which are
|
||||
// intentionally not perfect circles.
|
||||
return <ellipse cx={rx} cy={ry} rx={rx - sw / 2} ry={ry - sw / 2} />
|
||||
}
|
||||
},
|
||||
|
||||
getBounds(shape: EllipseShape) {
|
||||
shouldRender(prev, next) {
|
||||
return next.radius !== prev.radius || next.style !== prev.style
|
||||
},
|
||||
|
||||
getBounds(shape) {
|
||||
return Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
return Utils.getRotatedEllipseBounds(
|
||||
shape.point[0],
|
||||
|
@ -160,38 +151,39 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
|||
shape.rotation || 0
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
getRotatedBounds(shape: EllipseShape) {
|
||||
getRotatedBounds(shape) {
|
||||
return Utils.getBoundsFromPoints(Utils.getRotatedCorners(this.getBounds(shape), shape.rotation))
|
||||
}
|
||||
},
|
||||
|
||||
getCenter(shape: EllipseShape): number[] {
|
||||
getCenter(shape): number[] {
|
||||
return [shape.point[0] + shape.radius[0], shape.point[1] + shape.radius[1]]
|
||||
}
|
||||
},
|
||||
|
||||
hitTest(shape: EllipseShape, point: number[]) {
|
||||
return Utils.pointInBounds(point, this.getBounds(shape))
|
||||
}
|
||||
|
||||
hitTestBounds(shape: EllipseShape, bounds: TLBounds) {
|
||||
const rotatedCorners = Utils.getRotatedCorners(this.getBounds(shape), shape.rotation)
|
||||
|
||||
return (
|
||||
rotatedCorners.every((point) => Utils.pointInBounds(point, bounds)) ||
|
||||
intersectPolylineBounds(rotatedCorners, bounds).length > 0
|
||||
hitTest(shape, point: number[]) {
|
||||
return Utils.pointInEllipse(
|
||||
point,
|
||||
this.getCenter(shape),
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
getBindingPoint(
|
||||
shape: EllipseShape,
|
||||
fromShape: ArrowShape,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
padding: number,
|
||||
anywhere: boolean
|
||||
) {
|
||||
hitTestBounds(shape, bounds) {
|
||||
return (
|
||||
intersectBoundsEllipse(
|
||||
bounds,
|
||||
this.getCenter(shape),
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation
|
||||
).length > 0
|
||||
)
|
||||
},
|
||||
|
||||
getBindingPoint(shape, fromShape, point, origin, direction, padding, anywhere) {
|
||||
{
|
||||
const bounds = this.getBounds(shape)
|
||||
|
||||
|
@ -216,14 +208,6 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
|||
|
||||
distance = 0
|
||||
} else {
|
||||
// Find furthest intersection between ray from
|
||||
// origin through point and expanded bounds.
|
||||
// const intersection = Intersect.ray
|
||||
// .bounds(origin, direction, expandedBounds)
|
||||
// .filter((int) => int.didIntersect)
|
||||
// .map((int) => int.points[0])
|
||||
// .sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
|
||||
|
||||
let intersection = intersectRayEllipse(
|
||||
origin,
|
||||
direction,
|
||||
|
@ -287,10 +271,10 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
|||
distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transform(
|
||||
_shape: EllipseShape,
|
||||
_shape,
|
||||
bounds: TLBounds,
|
||||
{ scaleX, scaleY, initialShape }: TLTransformInfo<EllipseShape>
|
||||
) {
|
||||
|
@ -304,15 +288,19 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
|||
? -(rotation || 0)
|
||||
: rotation || 0,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transformSingle(shape: EllipseShape, bounds: TLBounds) {
|
||||
transformSingle(shape, bounds: TLBounds) {
|
||||
return {
|
||||
point: Vec.round([bounds.minX, bounds.minY]),
|
||||
radius: Vec.div([bounds.width, bounds.height], 2),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
function renderPath(shape: EllipseShape, boundsCenter: number[]) {
|
||||
const {
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Group shape Creates a shape: group 1`] = `
|
||||
Object {
|
||||
"childIndex": 1,
|
||||
"children": Array [],
|
||||
"id": "group",
|
||||
"name": "Group",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"rotation": 0,
|
||||
"size": Array [
|
||||
100,
|
||||
100,
|
||||
],
|
||||
"style": Object {
|
||||
"color": "Black",
|
||||
"dash": "Draw",
|
||||
"isFilled": false,
|
||||
"size": "Medium",
|
||||
},
|
||||
"type": "group",
|
||||
}
|
||||
`;
|
|
@ -1,7 +1,7 @@
|
|||
import { Group } from './group'
|
||||
|
||||
describe('Group shape', () => {
|
||||
it('Creates an instance', () => {
|
||||
new Group()
|
||||
it('Creates a shape', () => {
|
||||
expect(Group.create({ id: 'group' })).toMatchSnapshot('group')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,31 +1,26 @@
|
|||
import * as React from 'react'
|
||||
import { SVGContainer, TLBounds, Utils, TLShapeProps } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import { intersectRayBounds, intersectPolylineBounds } from '@tldraw/intersect'
|
||||
import { SVGContainer, Utils, ShapeUtil } from '@tldraw/core'
|
||||
import { defaultStyle, getPerfectDashProps } from '~shape/shape-styles'
|
||||
import {
|
||||
GroupShape,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawShapeType,
|
||||
TLDrawToolType,
|
||||
ColorStyle,
|
||||
DashStyle,
|
||||
ArrowShape,
|
||||
TLDrawMeta,
|
||||
} from '~types'
|
||||
import { getBoundsRectangle } from '../shared'
|
||||
|
||||
// TODO
|
||||
// [ ] - Find bounds based on common bounds of descendants
|
||||
export const Group = new ShapeUtil<GroupShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||
type: TLDrawShapeType.Group,
|
||||
|
||||
export class Group extends TLDrawShapeUtil<GroupShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Group as const
|
||||
toolType = TLDrawToolType.Bounds
|
||||
canBind = true
|
||||
toolType: TLDrawToolType.Bounds,
|
||||
|
||||
pathCache = new WeakMap<number[], string>([])
|
||||
canBind: true,
|
||||
|
||||
defaultProps: GroupShape = {
|
||||
defaultProps: {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.Group as const,
|
||||
type: TLDrawShapeType.Group,
|
||||
name: 'Group',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
|
@ -34,76 +29,63 @@ export class Group extends TLDrawShapeUtil<GroupShape, SVGSVGElement> {
|
|||
rotation: 0,
|
||||
children: [],
|
||||
style: defaultStyle,
|
||||
}
|
||||
},
|
||||
|
||||
shouldRender(prev: GroupShape, next: GroupShape) {
|
||||
return next.size !== prev.size || next.style !== prev.style
|
||||
}
|
||||
Component({ shape, isBinding, isHovered, isSelected, events }, ref) {
|
||||
const { id, size } = shape
|
||||
|
||||
render = React.forwardRef<SVGSVGElement, TLShapeProps<GroupShape, SVGSVGElement>>(
|
||||
({ shape, isBinding, isHovered, isSelected, events }, ref) => {
|
||||
const { id, size } = shape
|
||||
const sw = 2
|
||||
const w = Math.max(0, size[0] - sw / 2)
|
||||
const h = Math.max(0, size[1] - sw / 2)
|
||||
|
||||
const sw = 2
|
||||
const w = Math.max(0, size[0] - sw / 2)
|
||||
const h = Math.max(0, size[1] - sw / 2)
|
||||
const strokes: [number[], number[], number][] = [
|
||||
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
|
||||
[[w, sw / 2], [w, h], h - sw / 2],
|
||||
[[w, h], [sw / 2, h], w - sw / 2],
|
||||
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
|
||||
]
|
||||
|
||||
const strokes: [number[], number[], number][] = [
|
||||
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
|
||||
[[w, sw / 2], [w, h], h - sw / 2],
|
||||
[[w, h], [sw / 2, h], w - sw / 2],
|
||||
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
|
||||
]
|
||||
|
||||
const paths = strokes.map(([start, end, length], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
|
||||
length,
|
||||
sw,
|
||||
DashStyle.Dotted
|
||||
)
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={ColorStyle.Black}
|
||||
strokeWidth={isHovered || isSelected ? sw : 0}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
)
|
||||
})
|
||||
const paths = strokes.map(([start, end, length], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
|
||||
length,
|
||||
sw,
|
||||
DashStyle.Dotted
|
||||
)
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={-32}
|
||||
y={-32}
|
||||
width={size[0] + 64}
|
||||
height={size[1] + 64}
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={size[0]}
|
||||
height={size[1]}
|
||||
fill="transparent"
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</SVGContainer>
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={ColorStyle.Black}
|
||||
strokeWidth={isHovered || isSelected ? sw : 0}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
renderIndicator(shape: GroupShape) {
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={-32}
|
||||
y={-32}
|
||||
width={size[0] + 64}
|
||||
height={size[1] + 64}
|
||||
/>
|
||||
)}
|
||||
<rect x={0} y={0} width={size[0]} height={size[1]} fill="transparent" pointerEvents="all" />
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</SVGContainer>
|
||||
)
|
||||
},
|
||||
|
||||
Indicator({ shape }) {
|
||||
const [width, height] = shape.size
|
||||
|
||||
const sw = 2
|
||||
|
@ -118,126 +100,13 @@ export class Group extends TLDrawShapeUtil<GroupShape, SVGSVGElement> {
|
|||
height={Math.max(1, height - sw)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
getBounds(shape: GroupShape) {
|
||||
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
const [width, height] = shape.size
|
||||
return {
|
||||
minX: 0,
|
||||
maxX: width,
|
||||
minY: 0,
|
||||
maxY: height,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
})
|
||||
shouldRender(prev, next) {
|
||||
return next.size !== prev.size || next.style !== prev.style
|
||||
},
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
}
|
||||
|
||||
getRotatedBounds(shape: GroupShape) {
|
||||
return Utils.getBoundsFromPoints(Utils.getRotatedCorners(this.getBounds(shape), shape.rotation))
|
||||
}
|
||||
|
||||
getCenter(shape: GroupShape): number[] {
|
||||
return Utils.getBoundsCenter(this.getBounds(shape))
|
||||
}
|
||||
|
||||
getBindingPoint(
|
||||
shape: GroupShape,
|
||||
fromShape: ArrowShape,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
padding: number,
|
||||
anywhere: boolean
|
||||
) {
|
||||
const bounds = this.getBounds(shape)
|
||||
|
||||
const expandedBounds = Utils.expandBounds(bounds, padding)
|
||||
|
||||
let bindingPoint: number[]
|
||||
let distance: number
|
||||
|
||||
// The point must be inside of the expanded bounding box
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return
|
||||
|
||||
// The point is inside of the shape, so we'll assume the user is
|
||||
// indicating a specific point inside of the shape.
|
||||
if (anywhere) {
|
||||
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 {
|
||||
// Find furthest intersection between ray from
|
||||
// origin through point and expanded bounds.
|
||||
|
||||
// TODO: Make this a ray vs rounded rect intersection
|
||||
const intersection = intersectRayBounds(origin, direction, expandedBounds)
|
||||
.filter((int) => int.didIntersect)
|
||||
.map((int) => int.points[0])
|
||||
.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
|
||||
|
||||
// The anchor is a point between the handle and the intersection
|
||||
const anchor = Vec.med(point, intersection)
|
||||
|
||||
// If we're close to the center, snap to the center
|
||||
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
|
||||
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.pointInBounds(point, bounds)) {
|
||||
distance = 16
|
||||
} else {
|
||||
// If the binding point was close to the shape's center, snap to the center
|
||||
// Find the distance between the point and the real bounds of the shape
|
||||
distance = Math.max(
|
||||
16,
|
||||
Utils.getBoundsSides(bounds)
|
||||
.map((side) => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
|
||||
.sort((a, b) => a - b)[0]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
}
|
||||
}
|
||||
|
||||
hitTest(shape: GroupShape, point: number[]) {
|
||||
return Utils.pointInBounds(point, this.getBounds(shape))
|
||||
}
|
||||
|
||||
hitTestBounds(shape: GroupShape, bounds: TLBounds) {
|
||||
const rotatedCorners = Utils.getRotatedCorners(this.getBounds(shape), shape.rotation)
|
||||
|
||||
return (
|
||||
rotatedCorners.every((point) => Utils.pointInBounds(point, bounds)) ||
|
||||
intersectPolylineBounds(rotatedCorners, bounds).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
transform() {
|
||||
return {}
|
||||
}
|
||||
|
||||
transformSingle() {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
getBounds(shape) {
|
||||
return getBoundsRectangle(shape, this.boundsCache)
|
||||
},
|
||||
}))
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { PostIt } from './post-it'
|
||||
|
||||
describe('Post-It shape', () => {
|
||||
it('Creates an instance', () => {
|
||||
new PostIt()
|
||||
it('Creates a shape', () => {
|
||||
expect(PostIt.create).toBeDefined()
|
||||
// expect(PostIt.create({ id: 'postit' })).toMatchSnapshot('postit')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,22 +1,21 @@
|
|||
import * as React from 'react'
|
||||
import { TLBounds, Utils, TLTransformInfo, TLShapeProps, HTMLContainer } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import { HTMLContainer, ShapeUtil } from '@tldraw/core'
|
||||
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
|
||||
import { PostItShape, TLDrawShapeUtil, TLDrawShapeType, TLDrawToolType, ArrowShape } from '~types'
|
||||
import { intersectPolylineBounds, intersectRayBounds } from '@tldraw/intersect'
|
||||
import { PostItShape, TLDrawMeta, TLDrawShapeType, TLDrawToolType } from '~types'
|
||||
import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared'
|
||||
|
||||
// TODO
|
||||
// [ ] - Make sure that fill does not extend drawn shape at corners
|
||||
export const PostIt = new ShapeUtil<PostItShape, HTMLDivElement, TLDrawMeta>(() => ({
|
||||
type: TLDrawShapeType.PostIt,
|
||||
|
||||
export class PostIt extends TLDrawShapeUtil<PostItShape, HTMLDivElement> {
|
||||
type = TLDrawShapeType.PostIt as const
|
||||
toolType = TLDrawToolType.Bounds
|
||||
canBind = true
|
||||
pathCache = new WeakMap<number[], string>([])
|
||||
toolType: TLDrawToolType.Bounds,
|
||||
|
||||
defaultProps: PostItShape = {
|
||||
canBind: true,
|
||||
|
||||
pathCache: new WeakMap<number[], string>([]),
|
||||
|
||||
defaultProps: {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.PostIt as const,
|
||||
type: TLDrawShapeType.PostIt,
|
||||
name: 'PostIt',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
|
@ -25,43 +24,41 @@ export class PostIt extends TLDrawShapeUtil<PostItShape, HTMLDivElement> {
|
|||
text: '',
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
}
|
||||
},
|
||||
|
||||
shouldRender(prev: PostItShape, next: PostItShape) {
|
||||
shouldRender(prev, next) {
|
||||
return next.size !== prev.size || next.style !== prev.style
|
||||
}
|
||||
},
|
||||
|
||||
render = React.forwardRef<HTMLDivElement, TLShapeProps<PostItShape, HTMLDivElement>>(
|
||||
({ shape, isBinding, meta, events }, ref) => {
|
||||
const [count, setCount] = React.useState(0)
|
||||
Component({ events }, ref) {
|
||||
const [count, setCount] = React.useState(0)
|
||||
|
||||
return (
|
||||
<HTMLContainer ref={ref} {...events}>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: 'all',
|
||||
backgroundColor: 'rgba(255, 220, 100)',
|
||||
border: '1px solid black',
|
||||
fontFamily: 'sans-serif',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div onPointerDown={(e) => e.preventDefault()}>
|
||||
<input
|
||||
type="textarea"
|
||||
style={{ width: '100%', height: '50%', background: 'none' }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button onPointerDown={() => setCount((count) => count + 1)}>{count}</button>
|
||||
</div>
|
||||
return (
|
||||
<HTMLContainer ref={ref} {...events}>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: 'all',
|
||||
backgroundColor: 'rgba(255, 220, 100)',
|
||||
border: '1px solid black',
|
||||
fontFamily: 'sans-serif',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div onPointerDown={(e) => e.preventDefault()}>
|
||||
<input
|
||||
type="textarea"
|
||||
style={{ width: '100%', height: '50%', background: 'none' }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button onPointerDown={() => setCount((count) => count + 1)}>{count}</button>
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
},
|
||||
|
||||
renderIndicator(shape: PostItShape) {
|
||||
Indicator({ shape }) {
|
||||
const {
|
||||
style,
|
||||
size: [width, height],
|
||||
|
@ -82,162 +79,13 @@ export class PostIt extends TLDrawShapeUtil<PostItShape, HTMLDivElement> {
|
|||
height={Math.max(1, height - sw)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
getBounds(shape: PostItShape) {
|
||||
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
const [width, height] = shape.size
|
||||
return {
|
||||
minX: 0,
|
||||
maxX: width,
|
||||
minY: 0,
|
||||
maxY: height,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
})
|
||||
getBounds(shape) {
|
||||
return getBoundsRectangle(shape, this.boundsCache)
|
||||
},
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
}
|
||||
transform: transformRectangle,
|
||||
|
||||
getRotatedBounds(shape: PostItShape) {
|
||||
return Utils.getBoundsFromPoints(Utils.getRotatedCorners(this.getBounds(shape), shape.rotation))
|
||||
}
|
||||
|
||||
getCenter(shape: PostItShape): number[] {
|
||||
return Utils.getBoundsCenter(this.getBounds(shape))
|
||||
}
|
||||
|
||||
getBindingPoint(
|
||||
shape: PostItShape,
|
||||
fromShape: ArrowShape,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
padding: number,
|
||||
anywhere: boolean
|
||||
) {
|
||||
const bounds = this.getBounds(shape)
|
||||
|
||||
const expandedBounds = Utils.expandBounds(bounds, padding)
|
||||
|
||||
let bindingPoint: number[]
|
||||
let distance: number
|
||||
|
||||
// The point must be inside of the expanded bounding box
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return
|
||||
|
||||
// The point is inside of the shape, so we'll assume the user is
|
||||
// indicating a specific point inside of the shape.
|
||||
if (anywhere) {
|
||||
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 {
|
||||
// TODO: What if the shape has a curve? In that case, should we
|
||||
// intersect the circle-from-three-points instead?
|
||||
|
||||
// Find furthest intersection between ray from
|
||||
// origin through point and expanded bounds.
|
||||
|
||||
// TODO: Make this a ray vs rounded rect intersection
|
||||
const intersection = intersectRayBounds(origin, direction, expandedBounds)
|
||||
.filter((int) => int.didIntersect)
|
||||
.map((int) => int.points[0])
|
||||
.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
|
||||
// The anchor is a point between the handle and the intersection
|
||||
const anchor = Vec.med(point, intersection)
|
||||
|
||||
// If we're close to the center, snap to the center
|
||||
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
|
||||
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.pointInBounds(point, bounds)) {
|
||||
distance = 16
|
||||
} else {
|
||||
// If the binding point was close to the shape's center, snap to the center
|
||||
// Find the distance between the point and the real bounds of the shape
|
||||
distance = Math.max(
|
||||
16,
|
||||
Utils.getBoundsSides(bounds)
|
||||
.map((side) => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
|
||||
.sort((a, b) => a - b)[0]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
}
|
||||
}
|
||||
|
||||
hitTestBounds(shape: PostItShape, bounds: TLBounds) {
|
||||
const rotatedCorners = Utils.getRotatedCorners(this.getBounds(shape), shape.rotation)
|
||||
|
||||
return (
|
||||
rotatedCorners.every((point) => Utils.pointInBounds(point, bounds)) ||
|
||||
intersectPolylineBounds(rotatedCorners, bounds).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
transform(
|
||||
shape: PostItShape,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, transformOrigin, scaleX, scaleY }: TLTransformInfo<PostItShape>
|
||||
) {
|
||||
if (!shape.rotation && !shape.isAspectRatioLocked) {
|
||||
return {
|
||||
point: Vec.round([bounds.minX, bounds.minY]),
|
||||
size: Vec.round([bounds.width, bounds.height]),
|
||||
}
|
||||
} else {
|
||||
const size = Vec.round(
|
||||
Vec.mul(initialShape.size, Math.min(Math.abs(scaleX), Math.abs(scaleY)))
|
||||
)
|
||||
|
||||
const point = Vec.round([
|
||||
bounds.minX +
|
||||
(bounds.width - shape.size[0]) *
|
||||
(scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
|
||||
bounds.minY +
|
||||
(bounds.height - shape.size[1]) *
|
||||
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
|
||||
])
|
||||
|
||||
const rotation =
|
||||
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
|
||||
? initialShape.rotation
|
||||
? -initialShape.rotation
|
||||
: 0
|
||||
: initialShape.rotation
|
||||
|
||||
return {
|
||||
size,
|
||||
point,
|
||||
rotation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transformSingle(_shape: PostItShape, bounds: TLBounds) {
|
||||
return {
|
||||
size: Vec.round([bounds.width, bounds.height]),
|
||||
point: Vec.round([bounds.minX, bounds.minY]),
|
||||
}
|
||||
}
|
||||
}
|
||||
transformSingle: transformSingleRectangle,
|
||||
}))
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Rectangle shape Creates a shape: rectangle 1`] = `
|
||||
Object {
|
||||
"childIndex": 1,
|
||||
"id": "rectangle",
|
||||
"name": "Rectangle",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"rotation": 0,
|
||||
"size": Array [
|
||||
1,
|
||||
1,
|
||||
],
|
||||
"style": Object {
|
||||
"color": "Black",
|
||||
"dash": "Draw",
|
||||
"isFilled": false,
|
||||
"size": "Medium",
|
||||
},
|
||||
"type": "rectangle",
|
||||
}
|
||||
`;
|
|
@ -1,7 +1,7 @@
|
|||
import { Rectangle } from './rectangle'
|
||||
|
||||
describe('Rectangle shape', () => {
|
||||
it('Creates an instance', () => {
|
||||
new Rectangle()
|
||||
it('Creates a shape', () => {
|
||||
expect(Rectangle.create({ id: 'rectangle' })).toMatchSnapshot('rectangle')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,30 +1,23 @@
|
|||
import * as React from 'react'
|
||||
import { TLBounds, Utils, TLTransformInfo, TLShapeProps, SVGContainer } from '@tldraw/core'
|
||||
import { intersectRayBounds } from '@tldraw/intersect'
|
||||
import { Utils, SVGContainer, ShapeUtil } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import getStroke from 'perfect-freehand'
|
||||
import { getPerfectDashProps, defaultStyle, getShapeStyle } from '~shape/shape-styles'
|
||||
import {
|
||||
RectangleShape,
|
||||
DashStyle,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawShapeType,
|
||||
TLDrawToolType,
|
||||
ArrowShape,
|
||||
} from '~types'
|
||||
import { RectangleShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
|
||||
import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared'
|
||||
|
||||
// TODO
|
||||
// [ ] - Make sure that fill does not extend drawn shape at corners
|
||||
const pathCache = new WeakMap<number[], string>([])
|
||||
|
||||
export class Rectangle extends TLDrawShapeUtil<RectangleShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Rectangle as const
|
||||
toolType = TLDrawToolType.Bounds
|
||||
canBind = true
|
||||
pathCache = new WeakMap<number[], string>([])
|
||||
export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||
type: TLDrawShapeType.Rectangle,
|
||||
|
||||
defaultProps: RectangleShape = {
|
||||
toolType: TLDrawToolType.Bounds,
|
||||
|
||||
canBind: true,
|
||||
|
||||
defaultProps: {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.Rectangle as const,
|
||||
type: TLDrawShapeType.Rectangle,
|
||||
name: 'Rectangle',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
|
@ -32,115 +25,116 @@ export class Rectangle extends TLDrawShapeUtil<RectangleShape, SVGSVGElement> {
|
|||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
}
|
||||
},
|
||||
|
||||
shouldRender(prev: RectangleShape, next: RectangleShape) {
|
||||
shouldRender(prev, next) {
|
||||
return next.size !== prev.size || next.style !== prev.style
|
||||
}
|
||||
},
|
||||
|
||||
render = React.forwardRef<SVGSVGElement, TLShapeProps<RectangleShape, SVGSVGElement>>(
|
||||
({ shape, isBinding, meta, events }, ref) => {
|
||||
const { id, size, style } = shape
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const strokeWidth = +styles.strokeWidth
|
||||
Component({ shape, isBinding, meta, events }, ref) {
|
||||
const { id, size, style } = shape
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const strokeWidth = +styles.strokeWidth
|
||||
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
const pathData = Utils.getFromCache(this.pathCache, shape.size, () => renderPath(shape))
|
||||
this
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={strokeWidth / 2 - 32}
|
||||
y={strokeWidth / 2 - 32}
|
||||
width={Math.max(0, size[0] - strokeWidth / 2) + 64}
|
||||
height={Math.max(0, size[1] - strokeWidth / 2) + 64}
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
x={+styles.strokeWidth / 2}
|
||||
y={+styles.strokeWidth / 2}
|
||||
width={Math.max(0, size[0] - strokeWidth)}
|
||||
height={Math.max(0, size[1] - strokeWidth)}
|
||||
fill={style.isFilled ? styles.fill : 'none'}
|
||||
stroke="none"
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<path
|
||||
d={pathData}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={styles.strokeWidth}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const sw = strokeWidth * 1.618
|
||||
|
||||
const w = Math.max(0, size[0] - sw / 2)
|
||||
const h = Math.max(0, size[1] - sw / 2)
|
||||
|
||||
const strokes: [number[], number[], number][] = [
|
||||
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
|
||||
[[w, sw / 2], [w, h], h - sw / 2],
|
||||
[[w, h], [sw / 2, h], w - sw / 2],
|
||||
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
|
||||
]
|
||||
|
||||
const paths = strokes.map(([start, end, length], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
|
||||
length,
|
||||
sw,
|
||||
shape.style.dash
|
||||
)
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
)
|
||||
})
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
const pathData = Utils.getFromCache(pathCache, shape.size, () => renderPath(shape))
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={sw / 2 - 32}
|
||||
y={sw / 2 - 32}
|
||||
width={w + 64}
|
||||
height={h + 64}
|
||||
x={strokeWidth / 2 - 32}
|
||||
y={strokeWidth / 2 - 32}
|
||||
width={Math.max(0, size[0] - strokeWidth / 2) + 64}
|
||||
height={Math.max(0, size[1] - strokeWidth / 2) + 64}
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
width={w}
|
||||
height={h}
|
||||
fill={styles.fill}
|
||||
stroke="transparent"
|
||||
strokeWidth={sw}
|
||||
x={+styles.strokeWidth / 2}
|
||||
y={+styles.strokeWidth / 2}
|
||||
width={Math.max(0, size[0] - strokeWidth)}
|
||||
height={Math.max(0, size[1] - strokeWidth)}
|
||||
fill={style.isFilled ? styles.fill : 'none'}
|
||||
radius={strokeWidth}
|
||||
stroke="none"
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<path
|
||||
d={pathData}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={styles.strokeWidth}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
renderIndicator(shape: RectangleShape) {
|
||||
const sw = strokeWidth * 1.618
|
||||
|
||||
const w = Math.max(0, size[0] - sw / 2)
|
||||
const h = Math.max(0, size[1] - sw / 2)
|
||||
|
||||
const strokes: [number[], number[], number][] = [
|
||||
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
|
||||
[[w, sw / 2], [w, h], h - sw / 2],
|
||||
[[w, h], [sw / 2, h], w - sw / 2],
|
||||
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
|
||||
]
|
||||
|
||||
const paths = strokes.map(([start, end, length], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
|
||||
length,
|
||||
sw,
|
||||
shape.style.dash
|
||||
)
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={sw / 2 - 32}
|
||||
y={sw / 2 - 32}
|
||||
width={w + 64}
|
||||
height={h + 64}
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
width={w}
|
||||
height={h}
|
||||
fill={styles.fill}
|
||||
stroke="transparent"
|
||||
strokeWidth={sw}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</SVGContainer>
|
||||
)
|
||||
},
|
||||
|
||||
Indicator({ shape }) {
|
||||
const {
|
||||
style,
|
||||
size: [width, height],
|
||||
|
@ -161,156 +155,20 @@ export class Rectangle extends TLDrawShapeUtil<RectangleShape, SVGSVGElement> {
|
|||
height={Math.max(1, height - sw)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
getBounds(shape: RectangleShape) {
|
||||
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
const [width, height] = shape.size
|
||||
return {
|
||||
minX: 0,
|
||||
maxX: width,
|
||||
minY: 0,
|
||||
maxY: height,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
})
|
||||
getBounds(shape) {
|
||||
return getBoundsRectangle(shape, this.boundsCache)
|
||||
},
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
}
|
||||
transform: transformRectangle,
|
||||
|
||||
getRotatedBounds(shape: RectangleShape) {
|
||||
return Utils.getBoundsFromPoints(Utils.getRotatedCorners(this.getBounds(shape), shape.rotation))
|
||||
}
|
||||
transformSingle: transformSingleRectangle,
|
||||
}))
|
||||
|
||||
getCenter(shape: RectangleShape): number[] {
|
||||
return Utils.getBoundsCenter(this.getBounds(shape))
|
||||
}
|
||||
|
||||
getBindingPoint(
|
||||
shape: RectangleShape,
|
||||
fromShape: ArrowShape,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
padding: number,
|
||||
anywhere: boolean
|
||||
) {
|
||||
const bounds = this.getBounds(shape)
|
||||
|
||||
const expandedBounds = Utils.expandBounds(bounds, padding)
|
||||
|
||||
let bindingPoint: number[]
|
||||
let distance: number
|
||||
|
||||
// The point must be inside of the expanded bounding box
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return
|
||||
|
||||
// The point is inside of the shape, so we'll assume the user is
|
||||
// indicating a specific point inside of the shape.
|
||||
if (anywhere) {
|
||||
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 {
|
||||
// TODO: What if the shape has a curve? In that case, should we
|
||||
// intersect the circle-from-three-points instead?
|
||||
|
||||
// Find furthest intersection between ray from
|
||||
// origin through point and expanded bounds.
|
||||
|
||||
// TODO: Make this a ray vs rounded rect intersection
|
||||
const intersection = intersectRayBounds(origin, direction, expandedBounds)
|
||||
.filter((int) => int.didIntersect)
|
||||
.map((int) => int.points[0])
|
||||
.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
|
||||
// The anchor is a point between the handle and the intersection
|
||||
const anchor = Vec.med(point, intersection)
|
||||
|
||||
// If we're close to the center, snap to the center
|
||||
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
|
||||
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.pointInBounds(point, bounds)) {
|
||||
distance = 16
|
||||
} else {
|
||||
// If the binding point was close to the shape's center, snap to the center
|
||||
// Find the distance between the point and the real bounds of the shape
|
||||
distance = Math.max(
|
||||
16,
|
||||
Utils.getBoundsSides(bounds)
|
||||
.map((side) => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
|
||||
.sort((a, b) => a - b)[0]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
}
|
||||
}
|
||||
|
||||
transform(
|
||||
shape: RectangleShape,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, transformOrigin, scaleX, scaleY }: TLTransformInfo<RectangleShape>
|
||||
) {
|
||||
if (shape.rotation || shape.isAspectRatioLocked) {
|
||||
const size = Vec.round(
|
||||
Vec.mul(initialShape.size, Math.min(Math.abs(scaleX), Math.abs(scaleY)))
|
||||
)
|
||||
|
||||
const point = Vec.round([
|
||||
bounds.minX +
|
||||
(bounds.width - shape.size[0]) *
|
||||
(scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
|
||||
bounds.minY +
|
||||
(bounds.height - shape.size[1]) *
|
||||
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
|
||||
])
|
||||
|
||||
const rotation =
|
||||
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
|
||||
? initialShape.rotation
|
||||
? -initialShape.rotation
|
||||
: 0
|
||||
: initialShape.rotation
|
||||
|
||||
return {
|
||||
size,
|
||||
point,
|
||||
rotation,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
point: Vec.round([bounds.minX, bounds.minY]),
|
||||
size: Vec.round([bounds.width, bounds.height]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transformSingle(_shape: RectangleShape, bounds: TLBounds) {
|
||||
return {
|
||||
size: Vec.round([bounds.width, bounds.height]),
|
||||
point: Vec.round([bounds.minX, bounds.minY]),
|
||||
}
|
||||
}
|
||||
}
|
||||
/* -------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
function renderPath(shape: RectangleShape) {
|
||||
const styles = getShapeStyle(shape.style)
|
||||
|
|
83
packages/tldraw/src/shape/shapes/shared.tsx
Normal file
83
packages/tldraw/src/shape/shapes/shared.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { Vec } from '@tldraw/vec'
|
||||
import { TLBounds, TLShape, TLTransformInfo, Utils } from '@tldraw/core'
|
||||
|
||||
/**
|
||||
* Transform a rectangular shape.
|
||||
* @param shape
|
||||
* @param bounds
|
||||
* @param param2
|
||||
*/
|
||||
export function transformRectangle<T extends TLShape & { size: number[] }>(
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, transformOrigin, scaleX, scaleY }: TLTransformInfo<T>
|
||||
) {
|
||||
if (shape.rotation || initialShape.isAspectRatioLocked) {
|
||||
const size = Vec.round(Vec.mul(initialShape.size, Math.min(Math.abs(scaleX), Math.abs(scaleY))))
|
||||
|
||||
const point = Vec.round([
|
||||
bounds.minX +
|
||||
(bounds.width - shape.size[0]) * (scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
|
||||
bounds.minY +
|
||||
(bounds.height - shape.size[1]) *
|
||||
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
|
||||
])
|
||||
|
||||
const rotation =
|
||||
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
|
||||
? initialShape.rotation
|
||||
? -initialShape.rotation
|
||||
: 0
|
||||
: initialShape.rotation
|
||||
|
||||
return {
|
||||
size,
|
||||
point,
|
||||
rotation,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
point: Vec.round([bounds.minX, bounds.minY]),
|
||||
size: Vec.round([bounds.width, bounds.height]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single rectangular shape.
|
||||
* @param shape
|
||||
* @param bounds
|
||||
*/
|
||||
export function transformSingleRectangle<T extends TLShape & { size: number[] }>(
|
||||
shape: T,
|
||||
bounds: TLBounds
|
||||
) {
|
||||
return {
|
||||
size: Vec.round([bounds.width, bounds.height]),
|
||||
point: Vec.round([bounds.minX, bounds.minY]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bounds of a rectangular shape.
|
||||
* @param shape
|
||||
* @param boundsCache
|
||||
*/
|
||||
export function getBoundsRectangle<T extends TLShape & { size: number[] }>(
|
||||
shape: T,
|
||||
boundsCache: WeakMap<T, TLBounds>
|
||||
) {
|
||||
const bounds = Utils.getFromCache(boundsCache, shape, () => {
|
||||
const [width, height] = shape.size
|
||||
return {
|
||||
minX: 0,
|
||||
maxX: width,
|
||||
minY: 0,
|
||||
maxY: height,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
})
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Text shape Creates a shape: text 1`] = `
|
||||
Object {
|
||||
"childIndex": 1,
|
||||
"id": "text",
|
||||
"name": "Text",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
-0.5,
|
||||
-0.5,
|
||||
],
|
||||
"rotation": 0,
|
||||
"style": Object {
|
||||
"color": "Black",
|
||||
"dash": "Draw",
|
||||
"isFilled": false,
|
||||
"size": "Medium",
|
||||
},
|
||||
"text": " ",
|
||||
"type": "text",
|
||||
}
|
||||
`;
|
|
@ -1,7 +1,7 @@
|
|||
import { Text } from './text'
|
||||
|
||||
describe('Text shape', () => {
|
||||
it('Creates an instance', () => {
|
||||
new Text()
|
||||
it('Creates a shape', () => {
|
||||
expect(Text.create({ id: 'text' })).toMatchSnapshot('text')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,19 +1,11 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import { HTMLContainer, TLBounds, Utils, TLTransformInfo } from '@tldraw/core'
|
||||
import { HTMLContainer, TLBounds, Utils, ShapeUtil } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import { getShapeStyle, getFontStyle, defaultStyle } from '~shape/shape-styles'
|
||||
import {
|
||||
TextShape,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawShapeType,
|
||||
TLDrawToolType,
|
||||
ArrowShape,
|
||||
TLDrawShapeProps,
|
||||
} from '~types'
|
||||
import { TextShape, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
|
||||
import styled from '~styles'
|
||||
import TextAreaUtils from './text-utils'
|
||||
import { intersectPolylineBounds, intersectRayBounds } from '@tldraw/intersect'
|
||||
|
||||
const LETTER_SPACING = -1.5
|
||||
|
||||
|
@ -59,18 +51,20 @@ if (typeof window !== 'undefined') {
|
|||
melm = getMeasurementDiv()
|
||||
}
|
||||
|
||||
export class Text extends TLDrawShapeUtil<TextShape, HTMLDivElement> {
|
||||
type = TLDrawShapeType.Text as const
|
||||
toolType = TLDrawToolType.Text
|
||||
isAspectRatioLocked = true
|
||||
isEditableText = true
|
||||
canBind = true
|
||||
export const Text = new ShapeUtil<TextShape, HTMLDivElement, TLDrawMeta>(() => ({
|
||||
type: TLDrawShapeType.Text,
|
||||
|
||||
pathCache = new WeakMap<number[], string>([])
|
||||
toolType: TLDrawToolType.Text,
|
||||
|
||||
defaultProps = {
|
||||
isAspectRatioLocked: true,
|
||||
|
||||
isEditableText: true,
|
||||
|
||||
canBind: true,
|
||||
|
||||
defaultProps: {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.Text as const,
|
||||
type: TLDrawShapeType.Text,
|
||||
name: 'Text',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
|
@ -78,142 +72,135 @@ export class Text extends TLDrawShapeUtil<TextShape, HTMLDivElement> {
|
|||
rotation: 0,
|
||||
text: ' ',
|
||||
style: defaultStyle,
|
||||
}
|
||||
},
|
||||
|
||||
create(props: Partial<TextShape>): TextShape {
|
||||
create(props) {
|
||||
const shape = { ...this.defaultProps, ...props }
|
||||
const bounds = this.getBounds(shape)
|
||||
shape.point = Vec.sub(shape.point, [bounds.width / 2, bounds.height / 2])
|
||||
return shape
|
||||
}
|
||||
},
|
||||
|
||||
shouldRender(prev: TextShape, next: TextShape): boolean {
|
||||
shouldRender(prev, next): boolean {
|
||||
return (
|
||||
next.text !== prev.text || next.style.scale !== prev.style.scale || next.style !== prev.style
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
render = React.forwardRef<HTMLDivElement, TLDrawShapeProps<TextShape, HTMLDivElement>>(
|
||||
({ shape, meta, isEditing, isBinding, onShapeChange, onShapeBlur, events }, ref) => {
|
||||
const rInput = React.useRef<HTMLTextAreaElement>(null)
|
||||
const { text, style } = shape
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const font = getFontStyle(shape.style)
|
||||
Component({ shape, meta, isEditing, isBinding, onShapeChange, onShapeBlur, events }, ref) {
|
||||
const rInput = React.useRef<HTMLTextAreaElement>(null)
|
||||
const { text, style } = shape
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const font = getFontStyle(shape.style)
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onShapeChange?.({ ...shape, text: normalizeText(e.currentTarget.value) })
|
||||
},
|
||||
[shape]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') return
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextAreaUtils.indent(e.currentTarget)
|
||||
}
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onShapeChange?.({ ...shape, text: normalizeText(e.currentTarget.value) })
|
||||
},
|
||||
[shape]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') return
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextAreaUtils.indent(e.currentTarget)
|
||||
}
|
||||
|
||||
onShapeChange?.({ ...shape, text: normalizeText(e.currentTarget.value) })
|
||||
}
|
||||
},
|
||||
[shape, onShapeChange]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
e.currentTarget.setSelectionRange(0, 0)
|
||||
onShapeBlur?.()
|
||||
},
|
||||
[isEditing, shape]
|
||||
)
|
||||
|
||||
const handleFocus = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (!isEditing) return
|
||||
if (document.activeElement === e.currentTarget) {
|
||||
e.currentTarget.select()
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e) => {
|
||||
if (isEditing) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEditing) {
|
||||
setTimeout(() => {
|
||||
const elm = rInput.current!
|
||||
elm.focus()
|
||||
elm.select()
|
||||
}, 0)
|
||||
} else {
|
||||
const elm = rInput.current!
|
||||
elm.setSelectionRange(0, 0)
|
||||
}
|
||||
}, [isEditing])
|
||||
},
|
||||
[shape, onShapeChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<HTMLContainer ref={ref} {...events}>
|
||||
<StyledWrapper isEditing={isEditing} onPointerDown={handlePointerDown}>
|
||||
<StyledTextArea
|
||||
ref={rInput}
|
||||
style={{
|
||||
font,
|
||||
color: styles.stroke,
|
||||
}}
|
||||
name="text"
|
||||
defaultValue={text}
|
||||
tabIndex={-1}
|
||||
autoComplete="false"
|
||||
autoCapitalize="false"
|
||||
autoCorrect="false"
|
||||
autoSave="false"
|
||||
placeholder=""
|
||||
color={styles.stroke}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
autoFocus={isEditing}
|
||||
isEditing={isEditing}
|
||||
isBinding={isBinding}
|
||||
readOnly={!isEditing}
|
||||
wrap="off"
|
||||
dir="auto"
|
||||
datatype="wysiwyg"
|
||||
/>
|
||||
</StyledWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
const handleBlur = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
e.currentTarget.setSelectionRange(0, 0)
|
||||
onShapeBlur?.()
|
||||
},
|
||||
[isEditing, shape]
|
||||
)
|
||||
|
||||
renderIndicator(): JSX.Element | null {
|
||||
const handleFocus = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (!isEditing) return
|
||||
if (document.activeElement === e.currentTarget) {
|
||||
e.currentTarget.select()
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e) => {
|
||||
if (isEditing) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEditing) {
|
||||
setTimeout(() => {
|
||||
const elm = rInput.current!
|
||||
elm.focus()
|
||||
elm.select()
|
||||
}, 0)
|
||||
} else {
|
||||
const elm = rInput.current!
|
||||
elm.setSelectionRange(0, 0)
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
return (
|
||||
<HTMLContainer ref={ref} {...events}>
|
||||
<StyledWrapper isEditing={isEditing} onPointerDown={handlePointerDown}>
|
||||
<StyledTextArea
|
||||
ref={rInput}
|
||||
style={{
|
||||
font,
|
||||
color: styles.stroke,
|
||||
}}
|
||||
name="text"
|
||||
defaultValue={text}
|
||||
tabIndex={-1}
|
||||
autoComplete="false"
|
||||
autoCapitalize="false"
|
||||
autoCorrect="false"
|
||||
autoSave="false"
|
||||
placeholder=""
|
||||
color={styles.stroke}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
autoFocus={isEditing}
|
||||
isEditing={isEditing}
|
||||
isBinding={isBinding}
|
||||
readOnly={!isEditing}
|
||||
wrap="off"
|
||||
dir="auto"
|
||||
datatype="wysiwyg"
|
||||
/>
|
||||
</StyledWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
},
|
||||
|
||||
Indicator() {
|
||||
return null
|
||||
// if (isEditing) return null
|
||||
},
|
||||
|
||||
// const { width, height } = this.getBounds(shape)
|
||||
|
||||
// return <rect className="tl-selected" width={width} height={height} />
|
||||
}
|
||||
|
||||
getBounds(shape: TextShape): TLBounds {
|
||||
getBounds(shape): TLBounds {
|
||||
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
if (!melm) {
|
||||
// We're in SSR
|
||||
|
@ -238,34 +225,9 @@ export class Text extends TLDrawShapeUtil<TextShape, HTMLDivElement> {
|
|||
})
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
}
|
||||
},
|
||||
|
||||
getRotatedBounds(shape: TextShape): TLBounds {
|
||||
return Utils.getBoundsFromPoints(Utils.getRotatedCorners(this.getBounds(shape), shape.rotation))
|
||||
}
|
||||
|
||||
getCenter(shape: TextShape): number[] {
|
||||
return Utils.getBoundsCenter(this.getBounds(shape))
|
||||
}
|
||||
|
||||
hitTest(shape: TextShape, point: number[]): boolean {
|
||||
return Utils.pointInBounds(point, this.getBounds(shape))
|
||||
}
|
||||
|
||||
hitTestBounds(shape: TextShape, bounds: TLBounds): boolean {
|
||||
const rotatedCorners = Utils.getRotatedCorners(this.getBounds(shape), shape.rotation)
|
||||
|
||||
return (
|
||||
rotatedCorners.every((point) => Utils.pointInBounds(point, bounds)) ||
|
||||
intersectPolylineBounds(rotatedCorners, bounds).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
transform(
|
||||
_shape: TextShape,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, scaleX, scaleY }: TLTransformInfo<TextShape>
|
||||
): Partial<TextShape> {
|
||||
transform(_shape, bounds, { initialShape, scaleX, scaleY }) {
|
||||
const {
|
||||
rotation = 0,
|
||||
style: { scale = 1 },
|
||||
|
@ -282,13 +244,9 @@ export class Text extends TLDrawShapeUtil<TextShape, HTMLDivElement> {
|
|||
scale: nextScale,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transformSingle(
|
||||
_shape: TextShape,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, scaleX, scaleY }: TLTransformInfo<TextShape>
|
||||
): Partial<TextShape> {
|
||||
transformSingle(_shape, bounds, { initialShape, scaleX, scaleY }) {
|
||||
const {
|
||||
style: { scale = 1 },
|
||||
} = initialShape
|
||||
|
@ -300,9 +258,9 @@ export class Text extends TLDrawShapeUtil<TextShape, HTMLDivElement> {
|
|||
scale: scale * Math.max(Math.abs(scaleY), Math.abs(scaleX)),
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onBoundsReset(shape: TextShape): Partial<TextShape> {
|
||||
onDoubleClickBoundsHandle(shape) {
|
||||
const center = this.getCenter(shape)
|
||||
|
||||
const newCenter = this.getCenter({
|
||||
|
@ -320,9 +278,9 @@ export class Text extends TLDrawShapeUtil<TextShape, HTMLDivElement> {
|
|||
},
|
||||
point: Vec.round(Vec.add(shape.point, Vec.sub(center, newCenter))),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onStyleChange(shape: TextShape): Partial<TextShape> {
|
||||
onStyleChange(shape) {
|
||||
const center = this.getCenter(shape)
|
||||
|
||||
this.boundsCache.delete(shape)
|
||||
|
@ -332,88 +290,12 @@ export class Text extends TLDrawShapeUtil<TextShape, HTMLDivElement> {
|
|||
return {
|
||||
point: Vec.round(Vec.add(shape.point, Vec.sub(center, newCenter))),
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
shouldDelete(shape: TextShape): boolean {
|
||||
return shape.text.trim().length === 0
|
||||
}
|
||||
|
||||
getBindingPoint(
|
||||
shape: TextShape,
|
||||
fromShape: ArrowShape,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
padding: number,
|
||||
anywhere: boolean
|
||||
) {
|
||||
const bounds = this.getBounds(shape)
|
||||
|
||||
const expandedBounds = Utils.expandBounds(bounds, padding)
|
||||
|
||||
let bindingPoint: number[]
|
||||
let distance: number
|
||||
|
||||
// The point must be inside of the expanded bounding box
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return
|
||||
|
||||
// The point is inside of the shape, so we'll assume the user is
|
||||
// indicating a specific point inside of the shape.
|
||||
if (anywhere) {
|
||||
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 {
|
||||
// Find furthest intersection between ray from
|
||||
// origin through point and expanded bounds.
|
||||
|
||||
// TODO: Make this a ray vs rounded rect intersection
|
||||
const intersection = intersectRayBounds(origin, direction, expandedBounds)
|
||||
.filter((int) => int.didIntersect)
|
||||
.map((int) => int.points[0])
|
||||
.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
|
||||
|
||||
// The anchor is a point between the handle and the intersection
|
||||
const anchor = Vec.med(point, intersection)
|
||||
|
||||
// If we're close to the center, snap to the center
|
||||
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
|
||||
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.pointInBounds(point, bounds)) {
|
||||
distance = 16
|
||||
} else {
|
||||
// If the binding point was close to the shape's center, snap to the center
|
||||
// Find the distance between the point and the real bounds of the shape
|
||||
distance = Math.max(
|
||||
16,
|
||||
Utils.getBoundsSides(bounds)
|
||||
.map((side) => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
|
||||
.sort((a, b) => a - b)[0]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
/* -------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
const StyledWrapper = styled('div', {
|
||||
width: '100%',
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { TLDR } from '~state/tldr'
|
||||
import { TLDrawState } from '~state'
|
||||
import { mockDocument } from '~test'
|
||||
import type { TLDrawShape } from '~types'
|
||||
import { TLDrawShape, TLDrawShapeType } from '~types'
|
||||
|
||||
describe('Delete command', () => {
|
||||
const tlstate = new TLDrawState()
|
||||
|
@ -61,12 +61,10 @@ describe('Delete command', () => {
|
|||
|
||||
tlstate
|
||||
.deselectAll()
|
||||
.create(
|
||||
TLDR.getShapeUtils({ type: 'arrow' } as TLDrawShape).create({
|
||||
id: 'arrow1',
|
||||
parentId: 'page1',
|
||||
})
|
||||
)
|
||||
.createShapes({
|
||||
id: 'arrow1',
|
||||
type: TLDrawShapeType.Arrow,
|
||||
})
|
||||
.select('arrow1')
|
||||
.startHandleSession([0, 0], 'start')
|
||||
.updateHandleSession([110, 110])
|
||||
|
@ -77,7 +75,7 @@ describe('Delete command', () => {
|
|||
expect(binding).toBeTruthy()
|
||||
expect(binding.fromId).toBe('arrow1')
|
||||
expect(binding.toId).toBe('rect3')
|
||||
expect(binding.handleId).toBe('start')
|
||||
expect(binding.meta.handleId).toBe('start')
|
||||
expect(tlstate.getShape('arrow1').handles?.start.bindingId).toBe(binding.id)
|
||||
|
||||
tlstate.select('rect3').delete()
|
||||
|
|
|
@ -76,7 +76,7 @@ export function group(
|
|||
// Create the group
|
||||
beforeShapes[groupId] = undefined
|
||||
|
||||
afterShapes[groupId] = TLDR.getShapeUtils({ type: TLDrawShapeType.Group } as TLDrawShape).create({
|
||||
afterShapes[groupId] = TLDR.getShapeUtils(TLDrawShapeType.Group).create({
|
||||
id: groupId,
|
||||
childIndex: groupChildIndex,
|
||||
parentId: groupParentId,
|
||||
|
|
|
@ -5,7 +5,7 @@ export function resetBounds(data: Data, ids: string[], pageId: string): TLDrawCo
|
|||
const { before, after } = TLDR.mutateShapes(
|
||||
data,
|
||||
ids,
|
||||
(shape) => TLDR.getShapeUtils(shape).onBoundsReset(shape),
|
||||
(shape) => TLDR.getShapeUtils(shape).onDoubleClickBoundsHandle(shape),
|
||||
pageId
|
||||
)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { TLDR } from '~state/tldr'
|
||||
import { TLDrawState } from '~state'
|
||||
import { mockDocument } from '~test'
|
||||
import { ArrowShape, Decoration, TLDrawShape } from '~types'
|
||||
import { ArrowShape, Decoration, TLDrawShape, TLDrawShapeType } from '~types'
|
||||
|
||||
describe('Toggle decoration command', () => {
|
||||
const tlstate = new TLDrawState()
|
||||
|
@ -32,12 +32,10 @@ describe('Toggle decoration command', () => {
|
|||
|
||||
it('does, undoes and redoes command', () => {
|
||||
tlstate
|
||||
.create(
|
||||
TLDR.getShapeUtils({ type: 'arrow' } as TLDrawShape).create({
|
||||
id: 'arrow1',
|
||||
parentId: 'page1',
|
||||
})
|
||||
)
|
||||
.createShapes({
|
||||
id: 'arrow1',
|
||||
type: TLDrawShapeType.Arrow,
|
||||
})
|
||||
.select('arrow1')
|
||||
|
||||
expect(tlstate.getShape<ArrowShape>('arrow1').decorations?.end).toBe(Decoration.Arrow)
|
||||
|
|
|
@ -28,7 +28,7 @@ describe('Arrow session', () => {
|
|||
expect(binding).toBeTruthy()
|
||||
expect(binding.fromId).toBe('arrow1')
|
||||
expect(binding.toId).toBe('target1')
|
||||
expect(binding.handleId).toBe('start')
|
||||
expect(binding.meta.handleId).toBe('start')
|
||||
expect(tlstate.appState.status.current).toBe(TLDrawStatus.Idle)
|
||||
expect(tlstate.getShape('arrow1').handles?.start.bindingId).toBe(binding.id)
|
||||
|
||||
|
@ -62,7 +62,7 @@ describe('Arrow session', () => {
|
|||
.select('arrow1')
|
||||
.startHandleSession([200, 200], 'start')
|
||||
.updateHandleSession([50, 50])
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.5, 0.5])
|
||||
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.5, 0.5])
|
||||
})
|
||||
|
||||
it('Snaps to the center', () => {
|
||||
|
@ -71,7 +71,7 @@ describe('Arrow session', () => {
|
|||
.select('arrow1')
|
||||
.startHandleSession([200, 200], 'start')
|
||||
.updateHandleSession([55, 55])
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.5, 0.5])
|
||||
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.5, 0.5])
|
||||
})
|
||||
|
||||
it('Binds at the bottom left', () => {
|
||||
|
@ -80,7 +80,7 @@ describe('Arrow session', () => {
|
|||
.select('arrow1')
|
||||
.startHandleSession([200, 200], 'start')
|
||||
.updateHandleSession([132, -32])
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([1, 0])
|
||||
expect(tlstate.bindings[0].meta.point).toStrictEqual([1, 0])
|
||||
})
|
||||
|
||||
it('Cancels the bind when off of the expanded bounds', () => {
|
||||
|
@ -100,7 +100,7 @@ describe('Arrow session', () => {
|
|||
.startHandleSession([200, 200], 'start')
|
||||
.updateHandleSession([91, 9])
|
||||
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.68, 0.13])
|
||||
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.68, 0.13])
|
||||
|
||||
tlstate.updateHandleSession([91, 9], false, false, true)
|
||||
})
|
||||
|
@ -112,11 +112,11 @@ describe('Arrow session', () => {
|
|||
.startHandleSession([200, 200], 'start')
|
||||
.updateHandleSession([91, 9])
|
||||
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.68, 0.13])
|
||||
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.68, 0.13])
|
||||
|
||||
tlstate.updateHandleSession([91, 9], false, false, true)
|
||||
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.75, 0.25])
|
||||
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.75, 0.25])
|
||||
})
|
||||
|
||||
it('ignores binding when alt is held', () => {
|
||||
|
@ -126,11 +126,11 @@ describe('Arrow session', () => {
|
|||
.startHandleSession([200, 200], 'start')
|
||||
.updateHandleSession([55, 45])
|
||||
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.5, 0.5])
|
||||
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.5, 0.5])
|
||||
|
||||
tlstate.updateHandleSession([55, 45], false, false, true)
|
||||
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.5, 0.5])
|
||||
expect(tlstate.bindings[0].meta.point).toStrictEqual([0.5, 0.5])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ export class ArrowSession implements Session {
|
|||
}
|
||||
|
||||
// First update the handle's next point
|
||||
const change = TLDR.getShapeUtils(shape).onHandleChange(
|
||||
const change = TLDR.getShapeUtils<ArrowShape>(shape.type).onHandleChange(
|
||||
shape,
|
||||
{
|
||||
[handleId]: handle,
|
||||
|
@ -77,7 +77,7 @@ export class ArrowSession implements Session {
|
|||
// If the handle changed produced no change, bail here
|
||||
if (!change) return
|
||||
|
||||
// If we've made it this far, the shape should be a new objet reference
|
||||
// If we've made it this far, the shape should be a new object reference
|
||||
// that incorporates the changes we've made due to the handle movement.
|
||||
let nextShape = { ...shape, ...change }
|
||||
|
||||
|
@ -124,7 +124,7 @@ export class ArrowSession implements Session {
|
|||
|
||||
target = TLDR.getShape(data, id, data.appState.currentPageId)
|
||||
|
||||
const util = TLDR.getShapeUtils(target)
|
||||
const util = TLDR.getShapeUtils<TLDrawShape>(target.type)
|
||||
|
||||
const bindingPoint = util.getBindingPoint(
|
||||
target,
|
||||
|
@ -143,10 +143,12 @@ export class ArrowSession implements Session {
|
|||
id: this.newBindingId,
|
||||
type: 'arrow',
|
||||
fromId: initialShape.id,
|
||||
handleId: this.handleId,
|
||||
toId: target.id,
|
||||
point: Vec.round(bindingPoint.point),
|
||||
distance: bindingPoint.distance,
|
||||
meta: {
|
||||
handleId: this.handleId,
|
||||
point: Vec.round(bindingPoint.point),
|
||||
distance: bindingPoint.distance,
|
||||
},
|
||||
}
|
||||
|
||||
break
|
||||
|
@ -191,7 +193,7 @@ export class ArrowSession implements Session {
|
|||
|
||||
// Now update the arrow in response to the new binding
|
||||
const targetUtils = TLDR.getShapeUtils(target)
|
||||
const arrowChange = TLDR.getShapeUtils(nextShape).onBindingChange(
|
||||
const arrowChange = TLDR.getShapeUtils<ArrowShape>(nextShape.type).onBindingChange(
|
||||
nextShape,
|
||||
binding,
|
||||
target,
|
||||
|
@ -300,9 +302,7 @@ export class ArrowSession implements Session {
|
|||
[data.appState.currentPageId]: {
|
||||
shapes: {
|
||||
[initialShape.id]: TLDR.onSessionComplete(
|
||||
data,
|
||||
TLDR.getShape(data, initialShape.id, data.appState.currentPageId),
|
||||
data.appState.currentPageId
|
||||
TLDR.getShape(data, initialShape.id, data.appState.currentPageId)
|
||||
),
|
||||
},
|
||||
bindings: afterBindings,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { brushUpdater, Utils } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import { Data, Session, TLDrawPatch, TLDrawStatus } from '~types'
|
||||
import { getShapeUtils } from '~shape'
|
||||
import { TLDR } from '~state/tldr'
|
||||
|
||||
export class BrushSession implements Session {
|
||||
|
@ -123,8 +122,8 @@ export function getBrushSnapshot(data: Data) {
|
|||
)
|
||||
.map((shape) => ({
|
||||
id: shape.id,
|
||||
util: getShapeUtils(shape),
|
||||
bounds: getShapeUtils(shape).getBounds(shape),
|
||||
util: TLDR.getShapeUtils(shape),
|
||||
bounds: TLDR.getShapeUtils(shape).getBounds(shape),
|
||||
selectId: TLDR.getTopParentId(data, shape.id, currentPageId),
|
||||
}))
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { TLDrawState } from '~state'
|
||||
import { mockDocument } from '~test'
|
||||
import { TLDR } from '~state/tldr'
|
||||
import { TLDrawShape, TLDrawStatus } from '~types'
|
||||
import { TLDrawShape, TLDrawShapeType, TLDrawStatus } from '~types'
|
||||
|
||||
describe('Handle session', () => {
|
||||
const tlstate = new TLDrawState()
|
||||
|
@ -9,12 +9,10 @@ describe('Handle session', () => {
|
|||
it('begins, updates and completes session', () => {
|
||||
tlstate
|
||||
.loadDocument(mockDocument)
|
||||
.create(
|
||||
TLDR.getShapeUtils({ type: 'arrow' } as TLDrawShape).create({
|
||||
id: 'arrow1',
|
||||
parentId: 'page1',
|
||||
})
|
||||
)
|
||||
.createShapes({
|
||||
id: 'arrow1',
|
||||
type: TLDrawShapeType.Arrow,
|
||||
})
|
||||
.select('arrow1')
|
||||
.startHandleSession([-10, -10], 'end')
|
||||
.updateHandleSession([10, 10])
|
||||
|
@ -28,10 +26,9 @@ describe('Handle session', () => {
|
|||
it('cancels session', () => {
|
||||
tlstate
|
||||
.loadDocument(mockDocument)
|
||||
.create({
|
||||
...TLDR.getShapeUtils({ type: 'arrow' } as TLDrawShape).defaultProps,
|
||||
.createShapes({
|
||||
type: TLDrawShapeType.Arrow,
|
||||
id: 'arrow1',
|
||||
parentId: 'page1',
|
||||
})
|
||||
.select('arrow1')
|
||||
.startHandleSession([-10, -10], 'end')
|
||||
|
|
|
@ -106,9 +106,7 @@ export class HandleSession implements Session {
|
|||
[pageId]: {
|
||||
shapes: {
|
||||
[initialShape.id]: TLDR.onSessionComplete(
|
||||
data,
|
||||
TLDR.getShape(data, this.initialShape.id, pageId),
|
||||
pageId
|
||||
TLDR.getShape(data, this.initialShape.id, pageId)
|
||||
),
|
||||
},
|
||||
},
|
||||
|
|
|
@ -9,12 +9,10 @@ describe('Text session', () => {
|
|||
it('begins, updates and completes session', () => {
|
||||
tlstate
|
||||
.loadDocument(mockDocument)
|
||||
.create(
|
||||
TLDR.getShapeUtils({ type: TLDrawShapeType.Text } as TLDrawShape).create({
|
||||
id: 'text1',
|
||||
parentId: 'page1',
|
||||
})
|
||||
)
|
||||
.createShapes({
|
||||
id: 'text1',
|
||||
type: TLDrawShapeType.Text,
|
||||
})
|
||||
.select('text1')
|
||||
.startTextSession('text1')
|
||||
.updateTextSession('Hello world')
|
||||
|
@ -28,12 +26,10 @@ describe('Text session', () => {
|
|||
it('cancels session', () => {
|
||||
tlstate
|
||||
.loadDocument(mockDocument)
|
||||
.create(
|
||||
TLDR.getShapeUtils({ type: TLDrawShapeType.Text } as TLDrawShape).create({
|
||||
id: 'text1',
|
||||
parentId: 'page1',
|
||||
})
|
||||
)
|
||||
.createShapes({
|
||||
id: 'text1',
|
||||
type: TLDrawShapeType.Text,
|
||||
})
|
||||
.select('text1')
|
||||
.startTextSession('text1')
|
||||
.updateTextSession('Hello world')
|
||||
|
|
|
@ -65,9 +65,7 @@ export class TextSession implements Session {
|
|||
[pageId]: {
|
||||
shapes: {
|
||||
[initialShape.id]: TLDR.onSessionComplete(
|
||||
data,
|
||||
TLDR.getShape(data, initialShape.id, pageId),
|
||||
pageId
|
||||
TLDR.getShape(data, initialShape.id, pageId)
|
||||
),
|
||||
},
|
||||
},
|
||||
|
@ -157,9 +155,7 @@ export class TextSession implements Session {
|
|||
[pageId]: {
|
||||
shapes: {
|
||||
[initialShape.id]: TLDR.onSessionComplete(
|
||||
data,
|
||||
TLDR.getShape(data, initialShape.id, pageId),
|
||||
pageId
|
||||
TLDR.getShape(data, initialShape.id, pageId)
|
||||
),
|
||||
},
|
||||
},
|
||||
|
|
|
@ -99,9 +99,7 @@ export class TransformSingleSession implements Session {
|
|||
|
||||
beforeShapes[initialShape.id] = initialShape
|
||||
afterShapes[initialShape.id] = TLDR.onSessionComplete(
|
||||
data,
|
||||
TLDR.getShape(data, initialShape.id, data.appState.currentPageId),
|
||||
data.appState.currentPageId
|
||||
TLDR.getShape(data, initialShape.id, data.appState.currentPageId)
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
@ -59,7 +59,6 @@ export class TransformSession implements Session {
|
|||
)
|
||||
|
||||
shapes[id] = TLDR.transform(
|
||||
data,
|
||||
TLDR.getShape(data, id, data.appState.currentPageId),
|
||||
newShapeBounds,
|
||||
{
|
||||
|
@ -68,8 +67,7 @@ export class TransformSession implements Session {
|
|||
scaleX: this.scaleX,
|
||||
scaleY: this.scaleY,
|
||||
transformOrigin,
|
||||
},
|
||||
data.appState.currentPageId
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -5,35 +5,37 @@ import type {
|
|||
ShapeStyles,
|
||||
ShapesWithProp,
|
||||
TLDrawShape,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawBinding,
|
||||
TLDrawPage,
|
||||
TLDrawCommand,
|
||||
TLDrawPatch,
|
||||
TLDrawShapeUtil,
|
||||
} from '~types'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
|
||||
export class TLDR {
|
||||
static getShapeUtils<T extends TLDrawShape>(
|
||||
shape: T | T['type']
|
||||
): TLDrawShapeUtil<T, HTMLElement | SVGElement> {
|
||||
return getShapeUtils(typeof shape === 'string' ? ({ type: shape } as T) : shape)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static getShapeUtils<T extends TLDrawShape>(type: T['type']): TLDrawShapeUtil<T>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static getShapeUtils<T extends TLDrawShape>(shape: T): TLDrawShapeUtil<T>
|
||||
static getShapeUtils<T extends TLDrawShape>(shape: T | T['type']) {
|
||||
return getShapeUtils<T>(typeof shape === 'string' ? shape : shape.type)
|
||||
}
|
||||
|
||||
static getSelectedShapes(data: Data, pageId: string) {
|
||||
const page = this.getPage(data, pageId)
|
||||
const selectedIds = this.getSelectedIds(data, pageId)
|
||||
const page = TLDR.getPage(data, pageId)
|
||||
const selectedIds = TLDR.getSelectedIds(data, pageId)
|
||||
return selectedIds.map((id) => page.shapes[id])
|
||||
}
|
||||
|
||||
static screenToWorld(data: Data, point: number[]) {
|
||||
const camera = this.getPageState(data, data.appState.currentPageId).camera
|
||||
const camera = TLDR.getPageState(data, data.appState.currentPageId).camera
|
||||
return Vec.sub(Vec.div(point, camera.zoom), camera.point)
|
||||
}
|
||||
|
||||
static getViewport(data: Data): TLBounds {
|
||||
const [minX, minY] = this.screenToWorld(data, [0, 0])
|
||||
const [maxX, maxY] = this.screenToWorld(data, [window.innerWidth, window.innerHeight])
|
||||
const [minX, minY] = TLDR.screenToWorld(data, [0, 0])
|
||||
const [maxX, maxY] = TLDR.screenToWorld(data, [window.innerWidth, window.innerHeight])
|
||||
|
||||
return {
|
||||
minX,
|
||||
|
@ -58,15 +60,15 @@ export class TLDR {
|
|||
}
|
||||
|
||||
static getSelectedIds(data: Data, pageId: string): string[] {
|
||||
return this.getPageState(data, pageId).selectedIds
|
||||
return TLDR.getPageState(data, pageId).selectedIds
|
||||
}
|
||||
|
||||
static getShapes(data: Data, pageId: string): TLDrawShape[] {
|
||||
return Object.values(this.getPage(data, pageId).shapes)
|
||||
return Object.values(TLDR.getPage(data, pageId).shapes)
|
||||
}
|
||||
|
||||
static getCamera(data: Data, pageId: string): TLPageState['camera'] {
|
||||
return this.getPageState(data, pageId).camera
|
||||
return TLDR.getPageState(data, pageId).camera
|
||||
}
|
||||
|
||||
static getShape<T extends TLDrawShape = TLDrawShape>(
|
||||
|
@ -74,56 +76,56 @@ export class TLDR {
|
|||
shapeId: string,
|
||||
pageId: string
|
||||
): T {
|
||||
return this.getPage(data, pageId).shapes[shapeId] as T
|
||||
return TLDR.getPage(data, pageId).shapes[shapeId] as T
|
||||
}
|
||||
|
||||
static getBounds<T extends TLDrawShape>(shape: T) {
|
||||
return getShapeUtils(shape).getBounds(shape)
|
||||
return TLDR.getShapeUtils(shape).getBounds(shape)
|
||||
}
|
||||
|
||||
static getRotatedBounds<T extends TLDrawShape>(shape: T) {
|
||||
return getShapeUtils(shape).getRotatedBounds(shape)
|
||||
return TLDR.getShapeUtils(shape).getRotatedBounds(shape)
|
||||
}
|
||||
|
||||
static getSelectedBounds(data: Data): TLBounds {
|
||||
return Utils.getCommonBounds(
|
||||
this.getSelectedShapes(data, data.appState.currentPageId).map((shape) =>
|
||||
getShapeUtils(shape).getBounds(shape)
|
||||
TLDR.getSelectedShapes(data, data.appState.currentPageId).map((shape) =>
|
||||
TLDR.getShapeUtils(shape).getBounds(shape)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static getParentId(data: Data, id: string, pageId: string) {
|
||||
return this.getShape(data, id, pageId).parentId
|
||||
return TLDR.getShape(data, id, pageId).parentId
|
||||
}
|
||||
|
||||
static getPointedId(data: Data, id: string, pageId: string): string {
|
||||
const page = this.getPage(data, pageId)
|
||||
const pageState = this.getPageState(data, data.appState.currentPageId)
|
||||
const shape = this.getShape(data, id, pageId)
|
||||
const page = TLDR.getPage(data, pageId)
|
||||
const pageState = TLDR.getPageState(data, data.appState.currentPageId)
|
||||
const shape = TLDR.getShape(data, id, pageId)
|
||||
if (!shape) return id
|
||||
|
||||
return shape.parentId === pageState.currentParentId || shape.parentId === page.id
|
||||
? id
|
||||
: this.getPointedId(data, shape.parentId, pageId)
|
||||
: TLDR.getPointedId(data, shape.parentId, pageId)
|
||||
}
|
||||
|
||||
static getDrilledPointedId(data: Data, id: string, pageId: string): string {
|
||||
const shape = this.getShape(data, id, pageId)
|
||||
const shape = TLDR.getShape(data, id, pageId)
|
||||
const { currentPageId } = data.appState
|
||||
const { currentParentId, pointedId } = this.getPageState(data, data.appState.currentPageId)
|
||||
const { currentParentId, pointedId } = TLDR.getPageState(data, data.appState.currentPageId)
|
||||
|
||||
return shape.parentId === currentPageId ||
|
||||
shape.parentId === pointedId ||
|
||||
shape.parentId === currentParentId
|
||||
? id
|
||||
: this.getDrilledPointedId(data, shape.parentId, pageId)
|
||||
: TLDR.getDrilledPointedId(data, shape.parentId, pageId)
|
||||
}
|
||||
|
||||
static getTopParentId(data: Data, id: string, pageId: string): string {
|
||||
const page = this.getPage(data, pageId)
|
||||
const pageState = this.getPageState(data, pageId)
|
||||
const shape = this.getShape(data, id, pageId)
|
||||
const page = TLDR.getPage(data, pageId)
|
||||
const pageState = TLDR.getPageState(data, pageId)
|
||||
const shape = TLDR.getShape(data, id, pageId)
|
||||
|
||||
if (shape.parentId === shape.id) {
|
||||
throw Error(`Shape has the same id as its parent! ${shape.id}`)
|
||||
|
@ -131,18 +133,18 @@ export class TLDR {
|
|||
|
||||
return shape.parentId === page.id || shape.parentId === pageState.currentParentId
|
||||
? id
|
||||
: this.getTopParentId(data, shape.parentId, pageId)
|
||||
: TLDR.getTopParentId(data, shape.parentId, pageId)
|
||||
}
|
||||
|
||||
// Get an array of a shape id and its descendant shapes' ids
|
||||
static getDocumentBranch(data: Data, id: string, pageId: string): string[] {
|
||||
const shape = this.getShape(data, id, pageId)
|
||||
const shape = TLDR.getShape(data, id, pageId)
|
||||
|
||||
if (shape.children === undefined) return [id]
|
||||
|
||||
return [
|
||||
id,
|
||||
...shape.children.flatMap((childId) => this.getDocumentBranch(data, childId, pageId)),
|
||||
...shape.children.flatMap((childId) => TLDR.getDocumentBranch(data, childId, pageId)),
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -158,10 +160,10 @@ export class TLDR {
|
|||
pageId: string,
|
||||
fn?: (shape: TLDrawShape) => K
|
||||
): (TLDrawShape | K)[] {
|
||||
const page = this.getPage(data, pageId)
|
||||
const page = TLDR.getPage(data, pageId)
|
||||
|
||||
const copies = this.getSelectedIds(data, pageId)
|
||||
.flatMap((id) => this.getDocumentBranch(data, id, pageId).map((id) => page.shapes[id]))
|
||||
const copies = TLDR.getSelectedIds(data, pageId)
|
||||
.flatMap((id) => TLDR.getDocumentBranch(data, id, pageId).map((id) => page.shapes[id]))
|
||||
.filter((shape) => !shape.isLocked)
|
||||
.map(Utils.deepClone)
|
||||
|
||||
|
@ -184,7 +186,7 @@ export class TLDR {
|
|||
pageId: string,
|
||||
fn?: (shape: TLDrawShape) => K
|
||||
): (TLDrawShape | K)[] {
|
||||
const copies = this.getSelectedShapes(data, pageId)
|
||||
const copies = TLDR.getSelectedShapes(data, pageId)
|
||||
.filter((shape) => !shape.isLocked)
|
||||
.map(Utils.deepClone)
|
||||
|
||||
|
@ -198,7 +200,7 @@ export class TLDR {
|
|||
// For a given array of shape ids, an array of all other shapes that may be affected by a mutation to it.
|
||||
// Use this to decide which shapes to clone as before / after for a command.
|
||||
static getAllEffectedShapeIds(data: Data, ids: string[], pageId: string): string[] {
|
||||
const page = this.getPage(data, pageId)
|
||||
const page = TLDR.getPage(data, pageId)
|
||||
|
||||
const visited = new Set(ids)
|
||||
|
||||
|
@ -241,105 +243,6 @@ export class TLDR {
|
|||
return Array.from(visited.values())
|
||||
}
|
||||
|
||||
static recursivelyUpdateChildren<T extends TLDrawShape>(
|
||||
data: Data,
|
||||
id: string,
|
||||
beforeShapes: Record<string, Partial<TLDrawShape>> = {},
|
||||
afterShapes: Record<string, Partial<TLDrawShape>> = {},
|
||||
pageId: string
|
||||
): Data {
|
||||
const page = this.getPage(data, pageId)
|
||||
const shape = page.shapes[id] as T
|
||||
|
||||
if (shape.children !== undefined) {
|
||||
const deltas = this.getShapeUtils(shape).updateChildren(
|
||||
shape,
|
||||
shape.children.map((childId) => page.shapes[childId])
|
||||
)
|
||||
|
||||
if (deltas) {
|
||||
return deltas.reduce<Data>((cData, delta) => {
|
||||
if (!delta.id) throw Error('Delta must include an id!')
|
||||
const cPage = this.getPage(cData, pageId)
|
||||
const deltaShape = this.getShape(cData, delta.id, pageId)
|
||||
|
||||
if (!beforeShapes[delta.id]) {
|
||||
beforeShapes[delta.id] = deltaShape
|
||||
}
|
||||
cPage.shapes[delta.id] = this.getShapeUtils(deltaShape).mutate(deltaShape, delta)
|
||||
afterShapes[delta.id] = cPage.shapes[delta.id]
|
||||
|
||||
if (deltaShape.children !== undefined) {
|
||||
this.recursivelyUpdateChildren(cData, delta.id, beforeShapes, afterShapes, pageId)
|
||||
}
|
||||
|
||||
return cData
|
||||
}, data)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
static recursivelyUpdateParents<T extends TLDrawShape>(
|
||||
data: Data,
|
||||
id: string,
|
||||
beforeShapes: Record<string, Partial<TLDrawShape>> = {},
|
||||
afterShapes: Record<string, Partial<TLDrawShape>> = {},
|
||||
pageId: string
|
||||
): Data {
|
||||
const page = { ...this.getPage(data, pageId) }
|
||||
const shape = this.getShape<T>(data, id, pageId)
|
||||
|
||||
if (page.id === 'doc') {
|
||||
throw Error('wtf')
|
||||
}
|
||||
|
||||
if (shape.parentId !== page.id) {
|
||||
const parent = this.getShape(data, shape.parentId, pageId)
|
||||
|
||||
if (!parent.children) throw Error('No children in parent!')
|
||||
|
||||
const delta = this.getShapeUtils(parent).onChildrenChange(
|
||||
parent,
|
||||
parent.children.map((childId) => this.getShape(data, childId, pageId))
|
||||
)
|
||||
|
||||
if (delta) {
|
||||
if (!beforeShapes[parent.id]) {
|
||||
beforeShapes[parent.id] = parent
|
||||
}
|
||||
page.shapes[parent.id] = this.getShapeUtils(parent).mutate(parent, delta)
|
||||
afterShapes[parent.id] = page.shapes[parent.id]
|
||||
}
|
||||
|
||||
if (parent.parentId !== page.id) {
|
||||
return this.recursivelyUpdateParents(
|
||||
data,
|
||||
parent.parentId,
|
||||
beforeShapes,
|
||||
afterShapes,
|
||||
pageId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.appState.currentPageId === 'doc') {
|
||||
console.error('WTF?')
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
document: {
|
||||
...data.document,
|
||||
pages: {
|
||||
...data.document.pages,
|
||||
[page.id]: page,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
static updateBindings(
|
||||
data: Data,
|
||||
id: string,
|
||||
|
@ -347,30 +250,28 @@ export class TLDR {
|
|||
afterShapes: Record<string, Partial<TLDrawShape>> = {},
|
||||
pageId: string
|
||||
): Data {
|
||||
const page = { ...this.getPage(data, pageId) }
|
||||
const page = { ...TLDR.getPage(data, pageId) }
|
||||
return Object.values(page.bindings)
|
||||
.filter((binding) => binding.fromId === id || binding.toId === id)
|
||||
.reduce((cData, binding) => {
|
||||
if (!beforeShapes[binding.fromId]) {
|
||||
beforeShapes[binding.fromId] = Utils.deepClone(
|
||||
this.getShape(cData, binding.fromId, pageId)
|
||||
TLDR.getShape(cData, binding.fromId, pageId)
|
||||
)
|
||||
}
|
||||
|
||||
if (!beforeShapes[binding.toId]) {
|
||||
beforeShapes[binding.toId] = Utils.deepClone(this.getShape(cData, binding.toId, pageId))
|
||||
beforeShapes[binding.toId] = Utils.deepClone(TLDR.getShape(cData, binding.toId, pageId))
|
||||
}
|
||||
|
||||
this.onBindingChange(
|
||||
cData,
|
||||
this.getShape(cData, binding.fromId, pageId),
|
||||
TLDR.onBindingChange(
|
||||
TLDR.getShape(cData, binding.fromId, pageId),
|
||||
binding,
|
||||
this.getShape(cData, binding.toId, pageId),
|
||||
pageId
|
||||
TLDR.getShape(cData, binding.toId, pageId)
|
||||
)
|
||||
|
||||
afterShapes[binding.fromId] = Utils.deepClone(this.getShape(cData, binding.fromId, pageId))
|
||||
afterShapes[binding.toId] = Utils.deepClone(this.getShape(cData, binding.toId, pageId))
|
||||
afterShapes[binding.fromId] = Utils.deepClone(TLDR.getShape(cData, binding.fromId, pageId))
|
||||
afterShapes[binding.toId] = Utils.deepClone(TLDR.getShape(cData, binding.toId, pageId))
|
||||
|
||||
return cData
|
||||
}, data)
|
||||
|
@ -421,7 +322,7 @@ export class TLDR {
|
|||
const afterShapes: Record<string, Partial<T>> = {}
|
||||
|
||||
ids.forEach((id, i) => {
|
||||
const shape = this.getShape<T>(data, id, pageId)
|
||||
const shape = TLDR.getShape<T>(data, id, pageId)
|
||||
const change = fn(shape, i)
|
||||
if (change) {
|
||||
beforeShapes[id] = Object.fromEntries(
|
||||
|
@ -440,18 +341,9 @@ export class TLDR {
|
|||
},
|
||||
},
|
||||
})
|
||||
|
||||
const dataWithChildrenChanges = ids.reduce<Data>((cData, id) => {
|
||||
return this.recursivelyUpdateChildren(cData, id, beforeShapes, afterShapes, pageId)
|
||||
}, dataWithMutations)
|
||||
|
||||
const dataWithParentChanges = ids.reduce<Data>((cData, id) => {
|
||||
return this.recursivelyUpdateParents(cData, id, beforeShapes, afterShapes, pageId)
|
||||
}, dataWithChildrenChanges)
|
||||
|
||||
const dataWithBindingChanges = ids.reduce<Data>((cData, id) => {
|
||||
return this.updateBindings(cData, id, beforeShapes, afterShapes, pageId)
|
||||
}, dataWithParentChanges)
|
||||
return TLDR.updateBindings(cData, id, beforeShapes, afterShapes, pageId)
|
||||
}, dataWithMutations)
|
||||
|
||||
return {
|
||||
before: beforeShapes,
|
||||
|
@ -474,7 +366,7 @@ export class TLDR {
|
|||
|
||||
// If the shape is a child of another shape, also save that shape
|
||||
if (shape.parentId !== pageId) {
|
||||
const parent = this.getShape(data, shape.parentId, pageId)
|
||||
const parent = TLDR.getShape(data, shape.parentId, pageId)
|
||||
if (!parent.children) throw Error('No children in parent!')
|
||||
results.push([parent.id, { children: parent.children }])
|
||||
}
|
||||
|
@ -502,7 +394,7 @@ export class TLDR {
|
|||
|
||||
// If the shape is a child of a different shape, update its parent
|
||||
if (shape.parentId !== pageId) {
|
||||
const parent = this.getShape(data, shape.parentId, pageId)
|
||||
const parent = TLDR.getShape(data, shape.parentId, pageId)
|
||||
if (!parent.children) throw Error('No children in parent!')
|
||||
results.push([parent.id, { children: [...parent.children, shape.id] }])
|
||||
}
|
||||
|
@ -530,7 +422,7 @@ export class TLDR {
|
|||
): TLDrawCommand {
|
||||
pageId = pageId ? pageId : data.appState.currentPageId
|
||||
|
||||
const page = this.getPage(data, pageId)
|
||||
const page = TLDR.getPage(data, pageId)
|
||||
|
||||
const shapeIds =
|
||||
typeof shapes[0] === 'string'
|
||||
|
@ -615,73 +507,56 @@ export class TLDR {
|
|||
}
|
||||
}
|
||||
|
||||
static mutate<T extends TLDrawShape>(data: Data, shape: T, props: Partial<T>, pageId: string) {
|
||||
let next = getShapeUtils(shape).mutate(shape, props)
|
||||
|
||||
if (props.children) {
|
||||
next = this.onChildrenChange(data, next, pageId) || next
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
static onSessionComplete<T extends TLDrawShape>(data: Data, shape: T, pageId: string) {
|
||||
const delta = getShapeUtils(shape).onSessionComplete(shape)
|
||||
static onSessionComplete<T extends TLDrawShape>(shape: T) {
|
||||
const delta = TLDR.getShapeUtils(shape).onSessionComplete(shape)
|
||||
if (!delta) return shape
|
||||
return this.mutate(data, shape, delta, pageId)
|
||||
return { ...shape, ...delta }
|
||||
}
|
||||
|
||||
static onChildrenChange<T extends TLDrawShape>(data: Data, shape: T, pageId: string) {
|
||||
if (!shape.children) return
|
||||
|
||||
const delta = getShapeUtils(shape).onChildrenChange(
|
||||
const delta = TLDR.getShapeUtils(shape).onChildrenChange(
|
||||
shape,
|
||||
shape.children.map((id) => this.getShape(data, id, pageId))
|
||||
shape.children.map((id) => TLDR.getShape(data, id, pageId))
|
||||
)
|
||||
|
||||
if (!delta) return shape
|
||||
return this.mutate(data, shape, delta, pageId)
|
||||
|
||||
return { ...shape, ...delta }
|
||||
}
|
||||
|
||||
static onBindingChange<T extends TLDrawShape>(
|
||||
data: Data,
|
||||
shape: T,
|
||||
binding: TLDrawBinding,
|
||||
otherShape: TLDrawShape,
|
||||
pageId: string
|
||||
otherShape: TLDrawShape
|
||||
) {
|
||||
const delta = getShapeUtils(shape).onBindingChange(
|
||||
const delta = TLDR.getShapeUtils(shape).onBindingChange(
|
||||
shape,
|
||||
binding,
|
||||
otherShape,
|
||||
getShapeUtils(otherShape).getBounds(otherShape),
|
||||
getShapeUtils(otherShape).getCenter(otherShape)
|
||||
TLDR.getShapeUtils(otherShape).getBounds(otherShape),
|
||||
TLDR.getShapeUtils(otherShape).getCenter(otherShape)
|
||||
)
|
||||
if (!delta) return shape
|
||||
return this.mutate(data, shape, delta, pageId)
|
||||
|
||||
return { ...shape, ...delta }
|
||||
}
|
||||
|
||||
static transform<T extends TLDrawShape>(
|
||||
data: Data,
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
info: TLTransformInfo<T>,
|
||||
pageId: string
|
||||
) {
|
||||
const change = getShapeUtils(shape).transform(shape, bounds, info)
|
||||
if (!change) return shape
|
||||
return this.mutate(data, shape, change, pageId)
|
||||
static transform<T extends TLDrawShape>(shape: T, bounds: TLBounds, info: TLTransformInfo<T>) {
|
||||
const delta = TLDR.getShapeUtils(shape).transform(shape, bounds, info)
|
||||
if (!delta) return shape
|
||||
return { ...shape, ...delta }
|
||||
}
|
||||
|
||||
static transformSingle<T extends TLDrawShape>(
|
||||
data: Data,
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
info: TLTransformInfo<T>,
|
||||
pageId: string
|
||||
info: TLTransformInfo<T>
|
||||
) {
|
||||
const change = getShapeUtils(shape).transformSingle(shape, bounds, info)
|
||||
if (!change) return shape
|
||||
return this.mutate(data, shape, change, pageId)
|
||||
const delta = TLDR.getShapeUtils(shape).transformSingle(shape, bounds, info)
|
||||
if (!delta) return shape
|
||||
return { ...shape, ...delta }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
|
@ -689,11 +564,11 @@ export class TLDR {
|
|||
/* -------------------------------------------------- */
|
||||
|
||||
static updateParents(data: Data, pageId: string, changedShapeIds: string[]): void {
|
||||
const page = this.getPage(data, pageId)
|
||||
const page = TLDR.getPage(data, pageId)
|
||||
|
||||
if (changedShapeIds.length === 0) return
|
||||
|
||||
const { shapes } = this.getPage(data, pageId)
|
||||
const { shapes } = TLDR.getPage(data, pageId)
|
||||
|
||||
const parentToUpdateIds = Array.from(
|
||||
new Set(changedShapeIds.map((id) => shapes[id].parentId).values())
|
||||
|
@ -706,10 +581,10 @@ export class TLDR {
|
|||
throw Error('A shape is parented to a shape without a children array.')
|
||||
}
|
||||
|
||||
this.onChildrenChange(data, parent, pageId)
|
||||
TLDR.onChildrenChange(data, parent, pageId)
|
||||
}
|
||||
|
||||
this.updateParents(data, pageId, parentToUpdateIds)
|
||||
TLDR.updateParents(data, pageId, parentToUpdateIds)
|
||||
}
|
||||
|
||||
static getSelectedStyle(data: Data, pageId: string): ShapeStyles | false {
|
||||
|
@ -754,16 +629,16 @@ export class TLDR {
|
|||
/* -------------------------------------------------- */
|
||||
|
||||
static getBinding(data: Data, id: string, pageId: string): TLDrawBinding {
|
||||
return this.getPage(data, pageId).bindings[id]
|
||||
return TLDR.getPage(data, pageId).bindings[id]
|
||||
}
|
||||
|
||||
static getBindings(data: Data, pageId: string): TLDrawBinding[] {
|
||||
const page = this.getPage(data, pageId)
|
||||
const page = TLDR.getPage(data, pageId)
|
||||
return Object.values(page.bindings)
|
||||
}
|
||||
|
||||
static getBindableShapeIds(data: Data) {
|
||||
return this.getShapes(data, data.appState.currentPageId)
|
||||
return TLDR.getShapes(data, data.appState.currentPageId)
|
||||
.filter((shape) => TLDR.getShapeUtils(shape).canBind)
|
||||
.sort((a, b) => b.childIndex - a.childIndex)
|
||||
.map((shape) => shape.id)
|
||||
|
@ -772,7 +647,7 @@ export class TLDR {
|
|||
static getBindingsWithShapeIds(data: Data, ids: string[], pageId: string): TLDrawBinding[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
this.getBindings(data, pageId).filter((binding) => {
|
||||
TLDR.getBindings(data, pageId).filter((binding) => {
|
||||
return ids.includes(binding.toId) || ids.includes(binding.fromId)
|
||||
})
|
||||
).values()
|
||||
|
@ -782,7 +657,7 @@ export class TLDR {
|
|||
static getRelatedBindings(data: Data, ids: string[], pageId: string): TLDrawBinding[] {
|
||||
const changedShapeIds = new Set(ids)
|
||||
|
||||
const page = this.getPage(data, pageId)
|
||||
const page = TLDR.getPage(data, pageId)
|
||||
|
||||
// Find all bindings that we need to update
|
||||
const bindingsArr = Object.values(page.bindings)
|
||||
|
|
|
@ -18,7 +18,6 @@ import {
|
|||
import { Vec } from '@tldraw/vec'
|
||||
import {
|
||||
FlipType,
|
||||
TextShape,
|
||||
TLDrawDocument,
|
||||
MoveType,
|
||||
AlignType,
|
||||
|
@ -139,6 +138,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
this.session = undefined
|
||||
this.pointedId = undefined
|
||||
}
|
||||
|
||||
/* -------------------- Internal -------------------- */
|
||||
|
||||
onReady = () => {
|
||||
|
@ -413,10 +413,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
{
|
||||
appState: {
|
||||
activeTool: tool,
|
||||
activeToolType:
|
||||
tool === 'select'
|
||||
? 'select'
|
||||
: TLDR.getShapeUtils({ type: tool } as TLDrawShape).toolType,
|
||||
activeToolType: tool === 'select' ? 'select' : TLDR.getShapeUtils(tool).toolType,
|
||||
},
|
||||
},
|
||||
`selected_tool:${tool}`
|
||||
|
@ -831,7 +828,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
const childIndex =
|
||||
this.getShapes().sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
|
||||
|
||||
const shape = TLDR.getShapeUtils<TextShape>(TLDrawShapeType.Text).create({
|
||||
const shape = TLDR.getShapeUtils(TLDrawShapeType.Text).create({
|
||||
id: Utils.uniqueId(),
|
||||
parentId: this.appState.currentPageId,
|
||||
childIndex,
|
||||
|
@ -1638,7 +1635,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
if (shapes.length === 0) return this
|
||||
return this.create(
|
||||
...shapes.map((shape) => {
|
||||
return TLDR.getShapeUtils(shape as TLDrawShape).create({
|
||||
return TLDR.getShapeUtils(shape.type).create({
|
||||
...shape,
|
||||
parentId: shape.parentId || this.currentPageId,
|
||||
})
|
||||
|
@ -2044,7 +2041,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
|
||||
if (!this.appState.activeToolType) throw Error
|
||||
|
||||
const utils = TLDR.getShapeUtils({ type: this.appState.activeTool } as TLDrawShape)
|
||||
const utils = TLDR.getShapeUtils(this.appState.activeTool)
|
||||
|
||||
const shapes = this.getShapes()
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import type { TLBinding, TLShapeProps } from '@tldraw/core'
|
||||
import { TLShape, TLShapeUtil, TLHandle } from '@tldraw/core'
|
||||
import type { TLShape, TLShapeUtil, TLHandle } from '@tldraw/core'
|
||||
import type { TLPage, TLPageState } from '@tldraw/core'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { Command, Patch } from 'rko'
|
||||
|
@ -212,24 +212,20 @@ export type TLDrawShape =
|
|||
| GroupShape
|
||||
| PostItShape
|
||||
|
||||
export abstract class TLDrawShapeUtil<
|
||||
T extends TLDrawShape,
|
||||
E extends HTMLElement | SVGElement
|
||||
> extends TLShapeUtil<T, E> {
|
||||
abstract toolType: TLDrawToolType
|
||||
}
|
||||
|
||||
export type TLDrawShapeUtils = Record<
|
||||
TLDrawShapeType,
|
||||
TLDrawShapeUtil<TLDrawShape, HTMLElement | SVGElement>
|
||||
export type TLDrawShapeUtil<T extends TLDrawShape> = TLShapeUtil<
|
||||
T,
|
||||
any,
|
||||
TLDrawMeta,
|
||||
{
|
||||
toolType: TLDrawToolType
|
||||
}
|
||||
>
|
||||
|
||||
export interface ArrowBinding extends TLBinding {
|
||||
type: 'arrow'
|
||||
export type ArrowBinding = TLBinding<{
|
||||
handleId: keyof ArrowShape['handles']
|
||||
distance: number
|
||||
point: number[]
|
||||
}
|
||||
}>
|
||||
|
||||
export type TLDrawBinding = ArrowBinding
|
||||
|
||||
|
|
|
@ -11,10 +11,19 @@
|
|||
"docs"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"baseUrl": "src",
|
||||
"composite": false,
|
||||
"incremental": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"emitDeclarationOnly": true
|
||||
}
|
||||
"emitDeclarationOnly": true,
|
||||
"paths": {
|
||||
"~*": ["./*"],
|
||||
"@tldraw/core": ["../core"],
|
||||
"@tldraw/vec": ["../vec"],
|
||||
"@tldraw/intersect": ["../intersect"]
|
||||
}
|
||||
},
|
||||
"references": [{ "path": "../vec" }, { "path": "../intersect" }, { "path": "../core" }]
|
||||
}
|
||||
|
|
|
@ -8,9 +8,13 @@
|
|||
"baseUrl": "src",
|
||||
"emitDeclarationOnly": false,
|
||||
"paths": {
|
||||
"~*": ["./*"]
|
||||
"~*": ["./*"],
|
||||
"@tldraw/core": ["../core"],
|
||||
"@tldraw/vec": ["../vec"],
|
||||
"@tldraw/intersect": ["../intersect"]
|
||||
}
|
||||
},
|
||||
"references": [{ "path": "../vec" }, { "path": "../intersect" }, { "path": "../core" }],
|
||||
"typedocOptions": {
|
||||
"entryPoints": ["src/index.ts"],
|
||||
"out": "docs"
|
||||
|
|
|
@ -17,15 +17,11 @@
|
|||
"baseUrl": ".",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"-*": ["./*"]
|
||||
"-*": ["./*"],
|
||||
"@tldraw/tldraw": ["../tldraw"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/tldraw"
|
||||
},
|
||||
{ "path": "../../packages/core" }
|
||||
]
|
||||
"references": [{ "path": "../tldraw" }, { "path": "../core" }]
|
||||
}
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
"exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"],
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./packages/vec/src" },
|
||||
{ "path": "./packages/intersect/src" },
|
||||
{ "path": "./packages/tldraw/src" },
|
||||
{ "path": "./packages/core/src" }
|
||||
{ "path": "./packages/vec" },
|
||||
{ "path": "./packages/intersect" },
|
||||
{ "path": "./packages/tldraw" },
|
||||
{ "path": "./packages/core" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
|
|
Loading…
Reference in a new issue