moves to div renderer

This commit is contained in:
Steve Ruiz 2021-09-11 16:24:03 +01:00
parent bc96414bf7
commit e7987ca451
42 changed files with 1104 additions and 852 deletions

View file

@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import type { TLBounds } from '+types' import type { TLBounds } from '+types'
import { Utils } from '+utils' import { useBoundsEvents, usePosition } from '+hooks'
import { useBoundsEvents } from '+hooks' import { Container } from '+components/container'
import { SVGContainer } from '+components/svg-container'
interface BoundsBgProps { interface BoundsBgProps {
bounds: TLBounds bounds: TLBounds
@ -11,20 +13,11 @@ interface BoundsBgProps {
export function BoundsBg({ bounds, rotation }: BoundsBgProps): JSX.Element { export function BoundsBg({ bounds, rotation }: BoundsBgProps): JSX.Element {
const events = useBoundsEvents() const events = useBoundsEvents()
const { width, height } = bounds
const center = Utils.getBoundsCenter(bounds)
return ( return (
<rect <Container bounds={bounds} rotation={rotation}>
className="tl-bounds-bg" <SVGContainer>
width={Math.max(1, width)} <rect className="tl-bounds-bg" width={bounds.width} height={bounds.height} {...events} />
height={Math.max(1, height)} </SVGContainer>
transform={` </Container>
rotate(${rotation * (180 / Math.PI)},${center})
translate(${bounds.minX},${bounds.minY})
rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`}
{...events}
/>
) )
} }

View file

@ -1,10 +1,13 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import { TLBoundsEdge, TLBoundsCorner, TLBounds } from '+types' import { TLBoundsEdge, TLBoundsCorner, TLBounds } from '+types'
import { Utils } from '+utils'
import { CenterHandle } from './center-handle' import { CenterHandle } from './center-handle'
import { RotateHandle } from './rotate-handle' import { RotateHandle } from './rotate-handle'
import { CornerHandle } from './corner-handle' import { CornerHandle } from './corner-handle'
import { EdgeHandle } from './edge-handle' import { EdgeHandle } from './edge-handle'
import { usePosition } from '+hooks'
import { Container } from '+components/container'
import { SVGContainer } from '+components/svg-container'
interface BoundsProps { interface BoundsProps {
zoom: number zoom: number
@ -14,6 +17,27 @@ interface BoundsProps {
viewportWidth: number viewportWidth: number
} }
// function setTransform(elm: SVGSVGElement, padding: number, bounds: TLBounds, rotation: number) {
// const center = Utils.getBoundsCenter(bounds)
// const transform = `
// rotate(${rotation * (180 / Math.PI)},${center})
// translate(${bounds.minX - padding},${bounds.minY - padding})
// rotate(${(bounds.rotation || 0) * (180 / Math.PI)},0,0)`
// elm.setAttribute('transform', transform)
// elm.setAttribute('width', bounds.width + padding * 2 + 'px')
// elm.setAttribute('height', bounds.height + padding * 2 + 'px')
// }
// function setTransform(elm: HTMLDivElement, bounds: TLBounds, rotation = 0) {
// const transform = `
// translate(calc(${bounds.minX}px - var(--tl-padding)),calc(${bounds.minY}px - var(--tl-padding)))
// rotate(${rotation + (bounds.rotation || 0)}rad)
// `
// elm.style.setProperty('transform', transform)
// elm.style.setProperty('width', `calc(${bounds.width}px + (var(--tl-padding) * 2))`)
// elm.style.setProperty('height', `calc(${bounds.height}px + (var(--tl-padding) * 2))`)
// }
export function Bounds({ export function Bounds({
zoom, zoom,
bounds, bounds,
@ -23,65 +47,65 @@ export function Bounds({
}: BoundsProps): JSX.Element { }: BoundsProps): JSX.Element {
const targetSize = (viewportWidth < 768 ? 16 : 8) / zoom // Touch target size const targetSize = (viewportWidth < 768 ? 16 : 8) / zoom // Touch target size
const size = 8 / zoom // Touch target size const size = 8 / zoom // Touch target size
const center = Utils.getBoundsCenter(bounds)
return ( return (
<g <Container className="tl-bounds" bounds={bounds} rotation={rotation}>
pointerEvents="all" <SVGContainer>
transform={` <CenterHandle bounds={bounds} isLocked={isLocked} />
rotate(${rotation * (180 / Math.PI)},${center}) {!isLocked && (
translate(${bounds.minX},${bounds.minY}) <>
rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`} <EdgeHandle
> targetSize={targetSize}
<CenterHandle bounds={bounds} isLocked={isLocked} /> size={size}
{!isLocked && ( bounds={bounds}
<> edge={TLBoundsEdge.Top}
<EdgeHandle targetSize={targetSize} size={size} bounds={bounds} edge={TLBoundsEdge.Top} /> />
<EdgeHandle <EdgeHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
edge={TLBoundsEdge.Right} edge={TLBoundsEdge.Right}
/> />
<EdgeHandle <EdgeHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
edge={TLBoundsEdge.Bottom} edge={TLBoundsEdge.Bottom}
/> />
<EdgeHandle <EdgeHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
edge={TLBoundsEdge.Left} edge={TLBoundsEdge.Left}
/> />
<CornerHandle <CornerHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
corner={TLBoundsCorner.TopLeft} corner={TLBoundsCorner.TopLeft}
/> />
<CornerHandle <CornerHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
corner={TLBoundsCorner.TopRight} corner={TLBoundsCorner.TopRight}
/> />
<CornerHandle <CornerHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
corner={TLBoundsCorner.BottomRight} corner={TLBoundsCorner.BottomRight}
/> />
<CornerHandle <CornerHandle
targetSize={targetSize} targetSize={targetSize}
size={size} size={size}
bounds={bounds} bounds={bounds}
corner={TLBoundsCorner.BottomLeft} corner={TLBoundsCorner.BottomLeft}
/> />
<RotateHandle targetSize={targetSize} size={size} bounds={bounds} /> <RotateHandle targetSize={targetSize} size={size} bounds={bounds} />
</> </>
)} )}
</g> </SVGContainer>
</Container>
) )
} }

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import type { TLBounds } from '+types' import type { TLBounds } from '+types'
export class BrushUpdater { export class BrushUpdater {
ref = React.createRef<SVGRectElement>() ref = React.createRef<SVGSVGElement>()
isControlled = false isControlled = false
@ -18,8 +18,7 @@ export class BrushUpdater {
if (!elm) return if (!elm) return
elm.setAttribute('opacity', '1') elm.setAttribute('opacity', '1')
elm.setAttribute('x', bounds.minX.toString()) elm.setAttribute('transform', `translate(${bounds.minX.toString()}, ${bounds.minY.toString()})`)
elm.setAttribute('y', bounds.minY.toString())
elm.setAttribute('width', bounds.width.toString()) elm.setAttribute('width', bounds.width.toString())
elm.setAttribute('height', bounds.height.toString()) elm.setAttribute('height', bounds.height.toString())
} }

View file

@ -4,5 +4,9 @@ import { BrushUpdater } from './BrushUpdater'
export const brushUpdater = new BrushUpdater() export const brushUpdater = new BrushUpdater()
export const Brush = React.memo((): JSX.Element | null => { export const Brush = React.memo((): JSX.Element | null => {
return <rect ref={brushUpdater.ref} className="tl-brush" x={0} y={0} width={0} height={0} /> return (
<svg ref={brushUpdater.ref} opacity={0}>
<rect className="tl-brush" x={0} y={0} width="100%" height="100%" />
</svg>
)
}) })

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react' import * as React from 'react'
import { import {
usePreventNavigation, usePreventNavigation,
@ -18,24 +19,24 @@ function resetError() {
void null void null
} }
interface CanvasProps<T extends TLShape> { interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
page: TLPage<T, TLBinding> page: TLPage<T, TLBinding>
pageState: TLPageState pageState: TLPageState
hideBounds?: boolean hideBounds?: boolean
hideHandles?: boolean hideHandles?: boolean
hideIndicators?: boolean hideIndicators?: boolean
meta?: Record<string, unknown> meta?: M
} }
export function Canvas<T extends TLShape>({ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
page, page,
pageState, pageState,
meta, meta,
hideHandles = false, hideHandles = false,
hideBounds = false, hideBounds = false,
hideIndicators = false, hideIndicators = false,
}: CanvasProps<T>): JSX.Element { }: CanvasProps<T, M>): JSX.Element {
const rCanvas = React.useRef<SVGSVGElement>(null) const rCanvas = React.useRef<HTMLDivElement>(null)
const rContainer = React.useRef<HTMLDivElement>(null) const rContainer = React.useRef<HTMLDivElement>(null)
useResizeObserver(rCanvas) useResizeObserver(rCanvas)
@ -48,14 +49,14 @@ export function Canvas<T extends TLShape>({
const events = useCanvasEvents() const events = useCanvasEvents()
const rGroup = useCameraCss(rContainer, pageState) const rLayer = useCameraCss(rContainer, pageState)
return ( return (
<div className="tl-container" ref={rContainer}> <div className="tl-container" ref={rContainer}>
<svg id="canvas" className="tl-canvas" ref={rCanvas} {...events}> <div id="canvas" className="tl-absolute tl-canvas" ref={rCanvas} {...events}>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={resetError}> <ErrorBoundary FallbackComponent={ErrorFallback} onReset={resetError}>
<Defs zoom={pageState.camera.zoom} /> {/* <Defs zoom={pageState.camera.zoom} /> */}
<g ref={rGroup} id="tl-shapes"> <div ref={rLayer} className="tl-absolute tl-layer">
<Page <Page
page={page} page={page}
pageState={pageState} pageState={pageState}
@ -65,9 +66,9 @@ export function Canvas<T extends TLShape>({
meta={meta} meta={meta}
/> />
<Brush /> <Brush />
</g> </div>
</ErrorBoundary> </ErrorBoundary>
</svg> </div>
</div> </div>
) )
} }

View file

@ -0,0 +1,23 @@
import * as React from 'react'
import type { TLBounds } from '+types'
import { usePosition } from '+hooks'
interface ContainerProps {
bounds: TLBounds
rotation?: number
id?: string
className?: string
children: React.ReactNode
}
export const Container = React.memo(
({ id, bounds, rotation = 0, className, children }: ContainerProps) => {
const rBounds = usePosition(bounds, rotation)
return (
<div id={id} ref={rBounds} className={className + ' tl-positioned'}>
{children}
</div>
)
}
)

View file

@ -0,0 +1 @@
export * from './container'

View file

@ -1,24 +1,41 @@
import * as React from 'react' import * as React from 'react'
import { useHandleEvents } from '+hooks' import { useHandleEvents } from '+hooks'
import { Container } from '+components/container'
import Utils from '+utils'
import { SVGContainer } from '+components/svg-container'
interface HandleProps { interface HandleProps {
id: string id: string
point: number[] point: number[]
zoom: number
} }
export const Handle = React.memo(({ id, point, zoom }: HandleProps) => { export const Handle = React.memo(({ id, point }: HandleProps) => {
const events = useHandleEvents(id) const events = useHandleEvents(id)
const bounds = React.useMemo(
() =>
Utils.translateBounds(
{
minX: 0,
minY: 0,
maxX: 32,
maxY: 32,
width: 32,
height: 32,
},
point
),
[point]
)
return ( return (
<g className="tl-handles" transform={`translate(${point})`} {...events}> <Container bounds={bounds}>
<circle <SVGContainer>
id="handle-bg" <g className="tl-handles" {...events}>
className="tl-handle-bg" <circle className="tl-handle-bg" pointerEvents="all" />
pointerEvents="all" <circle className="tl-counter-scaled tl-handle" pointerEvents="none" r={4} />
r={20 / Math.max(1, zoom)} </g>
/> </SVGContainer>
<use href="#handle" /> </Container>
</g>
) )
}) })

View file

@ -4,6 +4,6 @@ import { Handles } from './handles'
describe('handles', () => { describe('handles', () => {
test('mounts component without crashing', () => { test('mounts component without crashing', () => {
renderWithContext(<Handles zoom={1} shape={mockUtils.box.create({})} />) renderWithContext(<Handles shape={mockUtils.box.create({ id: 'box' })} />)
}) })
}) })

View file

@ -1,35 +1,26 @@
import * as React from 'react' import * as React from 'react'
import { Vec } from '+utils' import { Vec } from '+utils'
import type { TLShape } from '+types' import type { TLShape } from '+types'
import { useTLContext } from '+hooks'
import { Handle } from './handle' import { Handle } from './handle'
interface HandlesProps { interface HandlesProps {
shape: TLShape shape: TLShape
zoom: number
} }
const toAngle = 180 / Math.PI export const Handles = React.memo(({ shape }: HandlesProps): JSX.Element | null => {
export const Handles = React.memo(({ shape, zoom }: HandlesProps): JSX.Element | null => {
const { shapeUtils } = useTLContext()
const center = shapeUtils[shape.type].getCenter(shape)
if (shape.handles === undefined) { if (shape.handles === undefined) {
return null return null
} }
return ( return (
<g transform={`rotate(${(shape.rotation || 0) * toAngle},${center})`}> <>
{Object.values(shape.handles).map((handle) => ( {Object.values(shape.handles).map((handle) => (
<Handle <Handle
key={shape.id + '_' + handle.id} key={shape.id + '_' + handle.id}
id={handle.id} id={handle.id}
point={Vec.add(handle.point, shape.point)} point={Vec.add(handle.point, shape.point)}
zoom={zoom}
/> />
))} ))}
</g> </>
) )
}) })

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react' import * as React from 'react'
import type { TLBinding, TLPage, TLPageState, TLShape } from '+types' import type { TLBinding, TLPage, TLPageState, TLShape } from '+types'
import { useSelection, useShapeTree, useHandles, useRenderOnResize, useTLContext } from '+hooks' import { useSelection, useShapeTree, useHandles, useRenderOnResize, useTLContext } from '+hooks'
@ -7,26 +8,26 @@ import { Handles } from '+components/handles'
import { ShapeNode } from '+components/shape' import { ShapeNode } from '+components/shape'
import { ShapeIndicator } from '+components/shape-indicator' import { ShapeIndicator } from '+components/shape-indicator'
interface PageProps<T extends TLShape> { interface PageProps<T extends TLShape, M extends Record<string, unknown>> {
page: TLPage<T, TLBinding> page: TLPage<T, TLBinding>
pageState: TLPageState pageState: TLPageState
hideBounds: boolean hideBounds: boolean
hideHandles: boolean hideHandles: boolean
hideIndicators: boolean hideIndicators: boolean
meta?: Record<string, unknown> meta?: M
} }
/** /**
* The Page component renders the current page. * The Page component renders the current page.
*/ */
export function Page<T extends TLShape>({ export function Page<T extends TLShape, M extends Record<string, unknown>>({
page, page,
pageState, pageState,
hideBounds, hideBounds,
hideHandles, hideHandles,
hideIndicators, hideIndicators,
meta, meta,
}: PageProps<T>): JSX.Element { }: PageProps<T, M>): JSX.Element {
const { callbacks, shapeUtils, inputs } = useTLContext() const { callbacks, shapeUtils, inputs } = useTLContext()
const shapeTree = useShapeTree(page, pageState, shapeUtils, inputs.size, meta, callbacks.onChange) const shapeTree = useShapeTree(page, pageState, shapeUtils, inputs.size, meta, callbacks.onChange)
@ -69,7 +70,7 @@ export function Page<T extends TLShape>({
variant="hovered" variant="hovered"
/> />
)} )}
{!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} zoom={zoom} />} {!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />}
</> </>
) )
} }

View file

@ -13,12 +13,15 @@ import { Canvas } from '../canvas'
import { Inputs } from '../../inputs' import { Inputs } from '../../inputs'
import { useTLTheme, TLContext, TLContextType } from '../../hooks' import { useTLTheme, TLContext, TLContextType } from '../../hooks'
export interface RendererProps<T extends TLShape, M extends Record<string, unknown>> export interface RendererProps<
extends Partial<TLCallbacks> { T extends TLShape,
E extends HTMLElement | SVGElement,
M extends Record<string, unknown>
> extends Partial<TLCallbacks> {
/** /**
* An object containing instances of your shape classes. * An object containing instances of your shape classes.
*/ */
shapeUtils: TLShapeUtils<T> shapeUtils: TLShapeUtils<T, E>
/** /**
* The current page, containing shapes and bindings. * The current page, containing shapes and bindings.
*/ */
@ -63,7 +66,11 @@ export interface RendererProps<T extends TLShape, M extends Record<string, unkno
* @param props * @param props
* @returns * @returns
*/ */
export function Renderer<T extends TLShape, M extends Record<string, unknown>>({ export function Renderer<
T extends TLShape,
E extends SVGElement | HTMLElement,
M extends Record<string, unknown>
>({
shapeUtils, shapeUtils,
page, page,
pageState, pageState,
@ -73,17 +80,18 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
hideIndicators = false, hideIndicators = false,
hideBounds = false, hideBounds = false,
...rest ...rest
}: RendererProps<T, M>): JSX.Element { }: RendererProps<T, E, M>): JSX.Element {
useTLTheme(theme) useTLTheme(theme)
const rScreenBounds = React.useRef<TLBounds>(null) const rScreenBounds = React.useRef<TLBounds>(null)
const rPageState = React.useRef<TLPageState>(pageState) const rPageState = React.useRef<TLPageState>(pageState)
React.useEffect(() => { React.useEffect(() => {
rPageState.current = pageState rPageState.current = pageState
}, [pageState]) }, [pageState])
const [context] = React.useState<TLContextType>(() => ({ const [context] = React.useState<TLContextType<T, E>>(() => ({
callbacks: rest, callbacks: rest,
shapeUtils, shapeUtils,
rScreenBounds, rScreenBounds,
@ -92,7 +100,7 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
})) }))
return ( return (
<TLContext.Provider value={context}> <TLContext.Provider value={context as TLContextType<T, E>}>
<Canvas <Canvas
page={page} page={page}
pageState={pageState} pageState={pageState}

View file

@ -4,6 +4,8 @@ import { ShapeIndicator } from './shape-indicator'
describe('shape indicator', () => { describe('shape indicator', () => {
test('mounts component without crashing', () => { test('mounts component without crashing', () => {
renderWithSvg(<ShapeIndicator shape={mockUtils.box.create({})} variant={'selected'} />) renderWithSvg(
<ShapeIndicator shape={mockUtils.box.create({ id: 'box1' })} variant={'selected'} />
)
}) })
}) })

View file

@ -1,20 +1,25 @@
import * as React from 'react' import * as React from 'react'
import type { TLShape } from '+types' import type { TLShape } from '+types'
import { useTLContext } from '+hooks' import { usePosition, useTLContext } from '+hooks'
export const ShapeIndicator = React.memo( export const ShapeIndicator = React.memo(
({ shape, variant }: { shape: TLShape; variant: 'selected' | 'hovered' }) => { ({ shape, variant }: { shape: TLShape; variant: 'selected' | 'hovered' }) => {
const { shapeUtils } = useTLContext() const { shapeUtils } = useTLContext()
const utils = shapeUtils[shape.type] const utils = shapeUtils[shape.type]
const bounds = utils.getBounds(shape)
const center = utils.getCenter(shape) const rBounds = usePosition(bounds, shape.rotation)
const rotation = (shape.rotation || 0) * (180 / Math.PI)
const transform = `rotate(${rotation}, ${center}) translate(${shape.point})`
return ( return (
<g className={variant === 'selected' ? 'tl-selected' : 'tl-hovered'} transform={transform}> <div
{shapeUtils[shape.type].renderIndicator(shape)} ref={rBounds}
</g> className={
'tl-indicator tl-absolute ' + (variant === 'selected' ? 'tl-selected' : 'tl-hovered')
}
>
<svg width="100%" height="100%">
<g className="tl-centered-g">{utils.renderIndicator(shape)}</g>
</svg>
</div>
) )
} }
) )

View file

@ -2,13 +2,15 @@ import { useTLContext } from '+hooks'
import * as React from 'react' import * as React from 'react'
import type { TLShapeUtil, TLRenderInfo, TLShape } from '+types' import type { TLShapeUtil, TLRenderInfo, TLShape } from '+types'
interface EditingShapeProps<T extends TLShape> extends TLRenderInfo { interface EditingShapeProps<T extends TLShape, E extends HTMLElement | SVGElement>
extends TLRenderInfo {
shape: T shape: T
utils: TLShapeUtil<T> utils: TLShapeUtil<T, E>
} }
export function EditingTextShape({ export function EditingTextShape<T extends TLShape, E extends HTMLElement | SVGElement>({
shape, shape,
events,
utils, utils,
isEditing, isEditing,
isBinding, isBinding,
@ -16,12 +18,12 @@ export function EditingTextShape({
isSelected, isSelected,
isCurrentParent, isCurrentParent,
meta, meta,
}: EditingShapeProps<TLShape>) { }: EditingShapeProps<T, E>) {
const { const {
callbacks: { onTextChange, onTextBlur, onTextFocus, onTextKeyDown, onTextKeyUp }, callbacks: { onTextChange, onTextBlur, onTextFocus, onTextKeyDown, onTextKeyUp },
} = useTLContext() } = useTLContext()
const ref = React.useRef<HTMLElement>(null) const ref = React.useRef<E>(null)
React.useEffect(() => { React.useEffect(() => {
// Firefox fix? // Firefox fix?
@ -32,18 +34,22 @@ export function EditingTextShape({
}, 0) }, 0)
}, [shape.id]) }, [shape.id])
return utils.render(shape, { return utils.render({
ref, ref,
shape,
isEditing, isEditing,
isHovered, isHovered,
isSelected, isSelected,
isCurrentParent, isCurrentParent,
isBinding, isBinding,
onTextChange, events: {
onTextBlur, ...events,
onTextFocus, onTextChange,
onTextKeyDown, onTextBlur,
onTextKeyUp, onTextFocus,
onTextKeyDown,
onTextKeyUp,
},
meta, meta,
}) })
} }

View file

@ -1,13 +1,9 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import type { TLShapeUtil, TLRenderInfo, TLShape } from '+types' import type { TLShapeUtil, TLRenderInfo, TLShape } from '+types'
interface RenderedShapeProps<T extends TLShape> extends TLRenderInfo {
shape: T
utils: TLShapeUtil<T>
}
export const RenderedShape = React.memo( export const RenderedShape = React.memo(
function RenderedShape({ <T extends TLShape, E extends SVGElement | HTMLElement, M extends Record<string, unknown>>({
shape, shape,
utils, utils,
isEditing, isEditing,
@ -15,16 +11,29 @@ export const RenderedShape = React.memo(
isHovered, isHovered,
isSelected, isSelected,
isCurrentParent, isCurrentParent,
events,
meta, meta,
}: RenderedShapeProps<TLShape>) { }: TLRenderInfo<M, E> & {
return utils.render(shape, { shape: T
isEditing, utils: TLShapeUtil<T, E>
isBinding, }) => {
isHovered, const ref = utils.getRef(shape)
isSelected,
isCurrentParent, return (
meta, <utils.render
}) ref={ref}
{...{
shape,
isEditing,
isBinding,
isHovered,
isSelected,
isCurrentParent,
meta,
events,
}}
/>
)
}, },
(prev, next) => { (prev, next) => {
// If these have changed, then definitely render // If these have changed, then definitely render

View file

@ -3,7 +3,7 @@ import type { IShapeTreeNode, TLShape, TLShapeUtils } from '+types'
import { Shape } from './shape' import { Shape } from './shape'
export const ShapeNode = React.memo( export const ShapeNode = React.memo(
<M extends Record<string, unknown>>({ ({
shape, shape,
utils, utils,
children, children,
@ -13,7 +13,7 @@ export const ShapeNode = React.memo(
isSelected, isSelected,
isCurrentParent, isCurrentParent,
meta, meta,
}: { utils: TLShapeUtils<TLShape> } & IShapeTreeNode<M>) => { }: { utils: TLShapeUtils<TLShape, HTMLElement | SVGElement> } & IShapeTreeNode<TLShape, any>) => {
return ( return (
<> <>
<Shape <Shape

View file

@ -6,7 +6,7 @@ describe('shape', () => {
test('mounts component without crashing', () => { test('mounts component without crashing', () => {
renderWithSvg( renderWithSvg(
<Shape <Shape
shape={mockUtils.box.create({})} shape={mockUtils.box.create({ id: 'box' })}
utils={mockUtils[mockUtils.box.type]} utils={mockUtils[mockUtils.box.type]}
isEditing={false} isEditing={false}
isBinding={false} isBinding={false}
@ -17,3 +17,6 @@ describe('shape', () => {
) )
}) })
}) })
// { shape: TLShape; ref: ForwardedRef<HTMLElement | SVGElement>; } & TLRenderInfo<any, any> & RefAttributes<HTMLElement | SVGElement>
// { shape: BoxShape; ref: ForwardedRef<any>; } & TLRenderInfo<any, any> & RefAttributes<any>'

View file

@ -1,10 +1,27 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import { useShapeEvents } from '+hooks' import { usePosition, useShapeEvents } from '+hooks'
import type { IShapeTreeNode, TLShape, TLShapeUtil } from '+types' import type { IShapeTreeNode, TLBounds, TLShape, TLShapeUtil } from '+types'
import { RenderedShape } from './rendered-shape' import { RenderedShape } from './rendered-shape'
import { EditingTextShape } from './editing-text-shape' import { EditingTextShape } from './editing-text-shape'
import { Container } from '+components/container'
import { SVGContainer } from '+components/svg-container'
export const Shape = <M extends Record<string, unknown>>({ // function setTransform(elm: HTMLDivElement, bounds: TLBounds, rotation = 0) {
// const transform = `
// translate(calc(${bounds.minX}px - var(--tl-padding)),calc(${bounds.minY}px - var(--tl-padding)))
// rotate(${rotation + (bounds.rotation || 0)}rad)
// `
// elm.style.setProperty('transform', transform)
// elm.style.setProperty('width', `calc(${bounds.width}px + (var(--tl-padding) * 2))`)
// elm.style.setProperty('height', `calc(${bounds.height}px + (var(--tl-padding) * 2))`)
// }
export const Shape = <
T extends TLShape,
E extends SVGElement | HTMLElement,
M extends Record<string, unknown>
>({
shape, shape,
utils, utils,
isEditing, isEditing,
@ -13,42 +30,46 @@ export const Shape = <M extends Record<string, unknown>>({
isSelected, isSelected,
isCurrentParent, isCurrentParent,
meta, meta,
}: { utils: TLShapeUtil<TLShape> } & IShapeTreeNode<M>) => { }: IShapeTreeNode<T, M> & {
utils: TLShapeUtil<T, E>
}) => {
const bounds = utils.getBounds(shape)
const events = useShapeEvents(shape.id, isCurrentParent) const events = useShapeEvents(shape.id, isCurrentParent)
const center = utils.getCenter(shape)
const rotation = (shape.rotation || 0) * (180 / Math.PI)
const transform = `rotate(${rotation}, ${center}) translate(${shape.point})`
return ( return (
<g <Container
className={isCurrentParent ? 'tl-shape-group tl-current-parent' : 'tl-shape-group'}
id={shape.id} id={shape.id}
transform={transform} className={'tl-shape' + (isCurrentParent ? 'tl-current-parent' : '')}
{...events} bounds={bounds}
rotation={shape.rotation}
> >
{isEditing && utils.isEditableText ? ( <SVGContainer>
<EditingTextShape {isEditing && utils.isEditableText ? (
shape={shape} <EditingTextShape
isBinding={false} shape={shape}
isCurrentParent={false} isBinding={false}
isEditing={true} isCurrentParent={false}
isHovered={isHovered} isEditing={true}
isSelected={isSelected} isHovered={isHovered}
utils={utils} isSelected={isSelected}
meta={meta} utils={utils}
/> meta={meta}
) : ( events={events}
<RenderedShape />
shape={shape} ) : (
utils={utils} <RenderedShape
isBinding={isBinding} shape={shape}
isCurrentParent={isCurrentParent} utils={utils as any}
isEditing={isEditing} isBinding={isBinding}
isHovered={isHovered} isCurrentParent={isCurrentParent}
isSelected={isSelected} isEditing={isEditing}
meta={meta} isHovered={isHovered}
/> isSelected={isSelected}
)} meta={meta as any}
</g> events={events}
/>
)}
</SVGContainer>
</Container>
) )
} }

View file

@ -0,0 +1 @@
export * from './svg-container'

View file

@ -0,0 +1,13 @@
import * as React from 'react'
interface SvgContainerProps {
children: React.ReactNode
}
export const SVGContainer = React.memo(({ children }: SvgContainerProps) => {
return (
<svg className="tl-positioned-svg">
<g className="tl-centered-g">{children}</g>
</svg>
)
})

View file

@ -14,3 +14,4 @@ export * from './useHandleEvents'
export * from './useHandles' export * from './useHandles'
export * from './usePreventNavigation' export * from './usePreventNavigation'
export * from './useBoundsEvents' export * from './useBoundsEvents'
export * from './usePosition'

View file

@ -3,21 +3,29 @@ import * as React from 'react'
import type { TLPageState } from '+types' import type { TLPageState } from '+types'
export function useCameraCss(ref: React.RefObject<HTMLDivElement>, pageState: TLPageState) { export function useCameraCss(ref: React.RefObject<HTMLDivElement>, pageState: TLPageState) {
const rGroup = React.useRef<SVGGElement>(null) const rLayer = React.useRef<HTMLDivElement>(null)
// Update the tl-zoom CSS variable when the zoom changes // Update the tl-zoom CSS variable when the zoom changes
React.useEffect(() => { React.useEffect(() => {
ref.current!.style.setProperty('--tl-zoom', pageState.camera.zoom.toString()) ref.current!.style.setProperty('--tl-zoom', pageState.camera.zoom.toString())
}, [pageState.camera.zoom]) }, [pageState.camera.zoom])
// Update the group's position when the camera moves or zooms
React.useEffect(() => { React.useEffect(() => {
const { ref.current!.style.setProperty('--tl-camera-x', pageState.camera.point[0] + 'px')
zoom, ref.current!.style.setProperty('--tl-camera-y', pageState.camera.point[1] + 'px')
point: [x = 0, y = 0], }, [pageState.camera.point])
} = pageState.camera
rGroup.current?.setAttribute('transform', `scale(${zoom}) translate(${x} ${y})`)
}, [pageState.camera])
return rGroup // Update the group's position when the camera moves or zooms
// React.useEffect(() => {
// const {
// zoom,
// point: [x = 0, y = 0],
// } = pageState.camera
// rLayer.current?.style.setProperty(
// 'transform',
// `scale(${zoom},${zoom}) translate(${x}px,${y}px)`
// )
// }, [pageState.camera])
return rLayer
} }

View file

@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TLBounds } from '+types'
export function usePosition(bounds: TLBounds, rotation = 0) {
const rBounds = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const elm = rBounds.current!
const transform = `
translate(calc(${bounds.minX}px - var(--tl-padding)),calc(${bounds.minY}px - var(--tl-padding)))
rotate(${rotation + (bounds.rotation || 0)}rad)
`
elm.style.setProperty('transform', transform)
elm.style.setProperty('width', `calc(${bounds.width}px + (var(--tl-padding) * 2))`)
elm.style.setProperty('height', `calc(${bounds.height}px + (var(--tl-padding) * 2))`)
}, [rBounds, bounds, rotation])
return rBounds
}

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
import * as React from 'react' import * as React from 'react'
export function usePreventNavigation(rCanvas: React.RefObject<SVGGElement>): void { export function usePreventNavigation(rCanvas: React.RefObject<HTMLDivElement>): void {
React.useEffect(() => { React.useEffect(() => {
const preventGestureNavigation = (event: TouchEvent) => { const preventGestureNavigation = (event: TouchEvent) => {
event.preventDefault() event.preventDefault()

View file

@ -6,10 +6,10 @@ function canvasToScreen(point: number[], camera: TLPageState['camera']): number[
return [(point[0] + camera.point[0]) * camera.zoom, (point[1] + camera.point[1]) * camera.zoom] return [(point[0] + camera.point[0]) * camera.zoom, (point[1] + camera.point[1]) * camera.zoom]
} }
export function useSelection<T extends TLShape>( export function useSelection<T extends TLShape, E extends HTMLElement | SVGElement>(
page: TLPage<T, TLBinding>, page: TLPage<T, TLBinding>,
pageState: TLPageState, pageState: TLPageState,
shapeUtils: TLShapeUtils<T> shapeUtils: TLShapeUtils<T, E>
) { ) {
const { rScreenBounds } = useTLContext() const { rScreenBounds } = useTLContext()
const { selectedIds } = pageState const { selectedIds } = pageState

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import type { import type {
@ -13,8 +14,8 @@ import type {
import { Utils, Vec } from '+utils' import { Utils, Vec } from '+utils'
function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>( function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
shape: TLShape, shape: T,
branch: IShapeTreeNode<M>[], branch: IShapeTreeNode<T, M>[],
shapes: TLPage<T, TLBinding>['shapes'], shapes: TLPage<T, TLBinding>['shapes'],
pageState: { pageState: {
bindingTargetId?: string bindingTargetId?: string
@ -27,7 +28,7 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
}, },
meta?: M meta?: M
) { ) {
const node: IShapeTreeNode<M> = { const node: IShapeTreeNode<T, M> = {
shape, shape,
isCurrentParent: pageState.currentParentId === shape.id, isCurrentParent: pageState.currentParentId === shape.id,
isEditing: pageState.editingId === shape.id, isEditing: pageState.editingId === shape.id,
@ -37,7 +38,7 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
(shape.children ? shape.children.includes(pageState.hoveredId) : false) (shape.children ? shape.children.includes(pageState.hoveredId) : false)
: false, : false,
isBinding: pageState.bindingTargetId === shape.id, isBinding: pageState.bindingTargetId === shape.id,
meta, meta: meta as any,
} }
branch.push(node) branch.push(node)
@ -54,14 +55,18 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
} }
} }
function shapeIsInViewport(shape: TLShape, bounds: TLBounds, viewport: TLBounds) { function shapeIsInViewport(bounds: TLBounds, viewport: TLBounds) {
return Utils.boundsContain(viewport, bounds) || Utils.boundsCollide(viewport, bounds) return Utils.boundsContain(viewport, bounds) || Utils.boundsCollide(viewport, bounds)
} }
export function useShapeTree<T extends TLShape, M extends Record<string, unknown>>( export function useShapeTree<
T extends TLShape,
E extends SVGElement | HTMLElement,
M extends Record<string, unknown>
>(
page: TLPage<T, TLBinding>, page: TLPage<T, TLBinding>,
pageState: TLPageState, pageState: TLPageState,
shapeUtils: TLShapeUtils<T>, shapeUtils: TLShapeUtils<T, E>,
size: number[], size: number[],
meta?: M, meta?: M,
onChange?: TLCallbacks['onChange'] onChange?: TLCallbacks['onChange']
@ -100,7 +105,7 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
// Don't hide selected shapes (this breaks certain drag interactions) // Don't hide selected shapes (this breaks certain drag interactions)
if ( if (
selectedIds.includes(shape.id) || selectedIds.includes(shape.id) ||
shapeIsInViewport(shape, shapeUtils[shape.type as T['type']].getBounds(shape), viewport) shapeIsInViewport(shapeUtils[shape.type as T['type']].getBounds(shape), viewport)
) { ) {
if (shape.parentId === page.id) { if (shape.parentId === page.id) {
shapesIdsToRender.add(shape.id) shapesIdsToRender.add(shape.id)
@ -132,7 +137,7 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
// Populate the shape tree // Populate the shape tree
const tree: IShapeTreeNode<M>[] = [] const tree: IShapeTreeNode<T, M>[] = []
const info = { ...pageState, bindingTargetId } const info = { ...pageState, bindingTargetId }

View file

@ -111,6 +111,9 @@ const tlcss = css`
.tl-container { .tl-container {
--tl-zoom: 1; --tl-zoom: 1;
--tl-scale: calc(1 / var(--tl-zoom)); --tl-scale: calc(1 / var(--tl-zoom));
--tl-camera-x: 0px;
--tl-camera-y: 0px;
--tl-padding: calc(32px * var(--tl-scale));
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@ -127,6 +130,37 @@ const tlcss = css`
box-sizing: border-box; box-sizing: border-box;
} }
.tl-absolute {
position: absolute;
top: 0px;
left: 0px;
transform-origin: center center;
}
.tl-positioned {
position: absolute;
top: 0px;
left: 0px;
transform-origin: center center;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.tl-positioned-svg {
width: 100%;
height: 100%;
pointer-events: none;
}
.tl-layer {
transform: scale(var(--tl-zoom), var(--tl-zoom))
translate(var(--tl-camera-x), var(--tl-camera-y));
height: 0;
width: 0;
}
.tl-counter-scaled { .tl-counter-scaled {
transform: scale(var(--tl-scale)); transform: scale(var(--tl-scale));
} }
@ -190,6 +224,10 @@ const tlcss = css`
pointer-events: none; pointer-events: none;
} }
.tl-bounds {
pointer-events: none;
}
.tl-bounds-center { .tl-bounds-center {
fill: transparent; fill: transparent;
stroke: var(--tl-selectStroke); stroke: var(--tl-selectStroke);
@ -210,10 +248,7 @@ const tlcss = css`
} }
.tl-canvas { .tl-canvas {
position: absolute;
overflow: hidden; overflow: hidden;
top: 0px;
left: 0px;
width: 100%; width: 100%;
height: 100%; height: 100%;
touch-action: none; touch-action: none;
@ -256,6 +291,7 @@ const tlcss = css`
fill: transparent; fill: transparent;
stroke: none; stroke: none;
pointer-events: all; pointer-events: all;
r: calc(20 / max(1, var(--tl-zoom)));
} }
.tl-binding-indicator { .tl-binding-indicator {
@ -264,18 +300,22 @@ const tlcss = css`
stroke: var(--tl-selected); stroke: var(--tl-selected);
} }
.tl-shape-group { .tl-shape {
outline: none; outline: none;
} }
.tl-shape-group > *[data-shy='true'] { .tl-shape > *[data-shy='true'] {
opacity: 0; opacity: 0;
} }
.tl-shape-group:hover > *[data-shy='true'] { .tl-shape:hover > *[data-shy='true'] {
opacity: 1; opacity: 1;
} }
.tl-centered-g {
transform: translate(var(--tl-padding), var(--tl-padding));
}
.tl-current-parent > *[data-shy='true'] { .tl-current-parent > *[data-shy='true'] {
opacity: 1; opacity: 1;
} }

View file

@ -2,16 +2,18 @@ import * as React from 'react'
import type { Inputs } from '+inputs' import type { Inputs } from '+inputs'
import type { TLCallbacks, TLShape, TLBounds, TLPageState, TLShapeUtils } from '+types' import type { TLCallbacks, TLShape, TLBounds, TLPageState, TLShapeUtils } from '+types'
export interface TLContextType { export interface TLContextType<T extends TLShape, E extends HTMLElement | SVGElement> {
id?: string id?: string
callbacks: Partial<TLCallbacks> callbacks: Partial<TLCallbacks>
shapeUtils: TLShapeUtils<TLShape> shapeUtils: TLShapeUtils<T, E>
rPageState: React.MutableRefObject<TLPageState> rPageState: React.MutableRefObject<TLPageState>
rScreenBounds: React.MutableRefObject<TLBounds | null> rScreenBounds: React.MutableRefObject<TLBounds | null>
inputs: Inputs inputs: Inputs
} }
export const TLContext = React.createContext<TLContextType>({} as TLContextType) export const TLContext = React.createContext<TLContextType<any, any>>(
{} as TLContextType<TLShape, HTMLElement | SVGElement>
)
export function useTLContext() { export function useTLContext() {
const context = React.useContext(TLContext) const context = React.useContext(TLContext)

View file

@ -1,13 +1,13 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import * as React from 'react' import * as React from 'react'
import { TLShapeUtil, TLShape, TLBounds, TLRenderInfo, TLTransformInfo } from '+types' import { TLShapeUtil, TLShape, TLShapeProps, TLBounds, TLRenderInfo, TLTransformInfo } from '+types'
import Utils, { Intersect } from '+utils' import Utils, { Intersect } from '+utils'
export interface BoxShape extends TLShape { export interface BoxShape extends TLShape {
size: number[] size: number[]
} }
export class Box extends TLShapeUtil<BoxShape> { export class Box extends TLShapeUtil<BoxShape, SVGGElement> {
type = 'box' type = 'box'
defaultProps = { defaultProps = {
@ -21,13 +21,15 @@ export class Box extends TLShapeUtil<BoxShape> {
rotation: 0, rotation: 0,
} }
create(props: Partial<BoxShape>) { render = React.forwardRef<SVGGElement, TLShapeProps<BoxShape, SVGGElement>>(
return { ...this.defaultProps, ...props } ({ shape, events }, ref) => {
} return (
<g ref={ref} {...events}>
render(shape: BoxShape, info: TLRenderInfo): JSX.Element { <rect width={shape.size[0]} height={shape.size[1]} fill="none" stroke="black" />
return <rect width={100} height={100} fill="none" stroke="black" /> </g>
} )
}
)
renderIndicator(shape: BoxShape) { renderIndicator(shape: BoxShape) {
return <rect width={100} height={100} /> return <rect width={100} height={100} />

View file

@ -1,6 +1,6 @@
import type { TLShapeUtils } from '+types' import type { TLShapeUtils } from '+types'
import { Box, BoxShape } from './box' import { Box, BoxShape } from './box'
export const mockUtils: TLShapeUtils<BoxShape> = { export const mockUtils: TLShapeUtils<BoxShape, SVGGElement> = {
box: new Box(), box: new Box(),
} }

View file

@ -2,6 +2,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* --------------------- Primary -------------------- */ /* --------------------- Primary -------------------- */
import React, { ForwardedRef } from 'react'
export type Patch<T> = Partial<{ [P in keyof T]: T | Partial<T> | Patch<T[P]> }> export type Patch<T> = Partial<{ [P in keyof T]: T | Partial<T> | Patch<T[P]> }>
export interface TLPage<T extends TLShape, B extends TLBinding> { export interface TLPage<T extends TLShape, B extends TLBinding> {
@ -54,21 +56,35 @@ export interface TLShape {
isAspectRatioLocked?: boolean isAspectRatioLocked?: boolean
} }
export type TLShapeUtils<T extends TLShape> = Record<string, TLShapeUtil<T>> export type TLShapeUtils<T extends TLShape, E extends SVGElement | HTMLElement> = Record<
string,
TLShapeUtil<T, E>
>
export interface TLRenderInfo<M = any, T extends SVGElement | HTMLElement = any> { export interface TLRenderInfo<M = any, E = any> {
ref?: React.RefObject<T>
isEditing: boolean isEditing: boolean
isBinding: boolean isBinding: boolean
isHovered: boolean isHovered: boolean
isSelected: boolean isSelected: boolean
isCurrentParent: boolean isCurrentParent: boolean
onTextChange?: TLCallbacks['onTextChange']
onTextBlur?: TLCallbacks['onTextBlur']
onTextFocus?: TLCallbacks['onTextFocus']
onTextKeyDown?: TLCallbacks['onTextKeyDown']
onTextKeyUp?: TLCallbacks['onTextKeyUp']
meta: M extends any ? M : never meta: M extends any ? M : never
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
onTextChange?: TLCallbacks['onTextChange']
onTextBlur?: TLCallbacks['onTextBlur']
onTextFocus?: TLCallbacks['onTextFocus']
onTextKeyDown?: TLCallbacks['onTextKeyDown']
onTextKeyUp?: TLCallbacks['onTextKeyUp']
}
}
export interface TLShapeProps<T extends TLShape, E = any, M = any> extends TLRenderInfo<M, E> {
ref: ForwardedRef<E>
shape: T
} }
export interface TLTool { export interface TLTool {
@ -261,18 +277,26 @@ export interface TLBezierCurveSegment {
/* Shape Utility */ /* Shape Utility */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
export abstract class TLShapeUtil<T extends TLShape> { export abstract class TLShapeUtil<T extends TLShape, E extends HTMLElement | SVGElement> {
refMap = new Map<string, React.RefObject<E>>()
boundsCache = new WeakMap<TLShape, TLBounds>() boundsCache = new WeakMap<TLShape, TLBounds>()
isEditableText = false isEditableText = false
isAspectRatioLocked = false isAspectRatioLocked = false
canEdit = false canEdit = false
canBind = false canBind = false
abstract type: T['type'] abstract type: T['type']
abstract defaultProps: T abstract defaultProps: T
abstract render(shape: T, info: TLRenderInfo): JSX.Element | null abstract render: React.ForwardRefExoticComponent<
{ shape: T; ref: React.ForwardedRef<E> } & TLRenderInfo & React.RefAttributes<E>
>
abstract renderIndicator(shape: T): JSX.Element | null abstract renderIndicator(shape: T): JSX.Element | null
@ -303,6 +327,14 @@ export abstract class TLShapeUtil<T extends TLShape> {
return [bounds.width / 2, bounds.height / 2] return [bounds.width / 2, bounds.height / 2]
} }
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)!
}
getBindingPoint( getBindingPoint(
shape: T, shape: T,
fromShape: TLShape, fromShape: TLShape,
@ -315,7 +347,8 @@ export abstract class TLShapeUtil<T extends TLShape> {
return undefined return undefined
} }
create(props: Partial<T>): T { create(props: { id: string } & Partial<T>): T {
this.refMap.set(props.id, React.createRef<E>())
return { ...this.defaultProps, ...props } return { ...this.defaultProps, ...props }
} }
@ -380,15 +413,15 @@ export abstract class TLShapeUtil<T extends TLShape> {
/* -------------------- Internal -------------------- */ /* -------------------- Internal -------------------- */
export interface IShapeTreeNode<M extends Record<string, unknown>> { export interface IShapeTreeNode<T extends TLShape, M extends Record<string, unknown>> {
shape: TLShape shape: T
children?: IShapeTreeNode<M>[] children?: IShapeTreeNode<TLShape, M>[]
isEditing: boolean isEditing: boolean
isBinding: boolean isBinding: boolean
isHovered: boolean isHovered: boolean
isSelected: boolean isSelected: boolean
isCurrentParent: boolean isCurrentParent: boolean
meta?: M meta?: M extends any ? M : never
} }
/* -------------------------------------------------- */ /* -------------------------------------------------- */

View file

@ -8,18 +8,31 @@ export const tldrawShapeUtils: TLDrawShapeUtils = {
[TLDrawShapeType.Arrow]: new Arrow(), [TLDrawShapeType.Arrow]: new Arrow(),
[TLDrawShapeType.Text]: new Text(), [TLDrawShapeType.Text]: new Text(),
[TLDrawShapeType.Group]: new Group(), [TLDrawShapeType.Group]: new Group(),
} } as TLDrawShapeUtils
export type ShapeByType<T extends keyof TLDrawShapeUtils> = TLDrawShapeUtils[T] export type ShapeByType<T extends keyof TLDrawShapeUtils> = TLDrawShapeUtils[T]
export function getShapeUtilsByType<T extends TLDrawShape>(shape: T): TLDrawShapeUtil<T> { export function getShapeUtilsByType<T extends TLDrawShape>(
return tldrawShapeUtils[shape.type as T['type']] as TLDrawShapeUtil<T> shape: T
): TLDrawShapeUtil<T, HTMLElement | SVGElement> {
return tldrawShapeUtils[shape.type as T['type']] as unknown as TLDrawShapeUtil<
T,
HTMLElement | SVGElement
>
} }
export function getShapeUtils<T extends TLDrawShape>(shape: T): TLDrawShapeUtil<T> { export function getShapeUtils<T extends TLDrawShape>(
return tldrawShapeUtils[shape.type as T['type']] as TLDrawShapeUtil<T> 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: Partial<TLDrawShape>) { export function createShape<TLDrawShape>(
type: TLDrawShapeType,
props: { id: string } & Partial<TLDrawShape>
) {
return tldrawShapeUtils[type].create(props) return tldrawShapeUtils[type].create(props)
} }

View file

@ -7,6 +7,7 @@ import {
Intersect, Intersect,
TLHandle, TLHandle,
TLPointerInfo, TLPointerInfo,
TLShapeProps,
} from '@tldraw/core' } from '@tldraw/core'
import getStroke from 'perfect-freehand' import getStroke from 'perfect-freehand'
import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles' import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles'
@ -22,7 +23,7 @@ import {
TLDrawRenderInfo, TLDrawRenderInfo,
} from '~types' } from '~types'
export class Arrow extends TLDrawShapeUtil<ArrowShape> { export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGGElement> {
type = TLDrawShapeType.Arrow as const type = TLDrawShapeType.Arrow as const
toolType = TLDrawToolType.Handle toolType = TLDrawToolType.Handle
canStyleFill = false canStyleFill = false
@ -70,62 +71,130 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
return next.handles !== prev.handles || next.style !== prev.style return next.handles !== prev.handles || next.style !== prev.style
} }
render = (shape: ArrowShape, { meta }: TLDrawRenderInfo) => { render = React.forwardRef<SVGGElement, TLShapeProps<ArrowShape, SVGGElement>>(
const { ({ shape, meta, events }) => {
handles: { start, bend, end }, const {
decorations = {}, handles: { start, bend, end },
style, decorations = {},
} = shape 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 shaftPath: JSX.Element | null
let startArrowHead: { left: number[]; right: number[] } | undefined let startArrowHead: { left: number[]; right: number[] } | undefined
let endArrowHead: { left: number[]; right: number[] } | undefined let endArrowHead: { left: number[]; right: number[] } | undefined
if (isStraightLine) { if (isStraightLine) {
const sw = strokeWidth * (isDraw ? 1.25 : 1.618) const sw = strokeWidth * (isDraw ? 1.25 : 1.618)
const path = Utils.getFromCache(this.pathCache, shape, () => const path = Utils.getFromCache(this.pathCache, shape, () =>
isDraw isDraw
? renderFreehandArrowShaft(shape) ? renderFreehandArrowShaft(shape)
: 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point) : 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point)
) )
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
arrowDist, arrowDist,
sw, sw,
shape.style.dash, shape.style.dash,
2 2
) )
if (decorations.start) { if (decorations.start) {
startArrowHead = getStraightArrowHeadPoints(start.point, end.point, arrowHeadLength) startArrowHead = getStraightArrowHeadPoints(start.point, end.point, arrowHeadLength)
} }
if (decorations.end) { if (decorations.end) {
endArrowHead = getStraightArrowHeadPoints(end.point, start.point, arrowHeadLength) endArrowHead = getStraightArrowHeadPoints(end.point, start.point, arrowHeadLength)
} }
// Straight arrow path // Straight arrow path
shaftPath = shaftPath =
arrowDist > 2 ? ( 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 = (
<> <>
<path <path
d={path} d={path}
fill="none" fill="none"
stroke="transparent"
strokeWidth={Math.max(8, strokeWidth * 2)} strokeWidth={Math.max(8, strokeWidth * 2)}
strokeDasharray="none" strokeDasharray="none"
strokeDashoffset="none" strokeDashoffset="none"
@ -135,7 +204,7 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
/> />
<path <path
d={path} d={path}
fill={styles.stroke} fill={isDraw ? styles.stroke : 'none'}
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={sw} strokeWidth={sw}
strokeDasharray={strokeDasharray} strokeDasharray={strokeDasharray}
@ -145,110 +214,46 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
pointerEvents="stroke" 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) { const sw = strokeWidth * 1.618
endArrowHead = getCurvedArrowHeadPoints(
end.point,
arrowHeadLength,
center,
radius,
length >= 0
)
}
// Curved arrow path return (
shaftPath = ( <g {...events}>
<> <g pointerEvents="none">
<path {shaftPath}
d={path} {startArrowHead && (
fill="none" <path
stroke="transparent" d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
strokeWidth={Math.max(8, strokeWidth * 2)} fill="none"
strokeDasharray="none" stroke={styles.stroke}
strokeDashoffset="none" strokeWidth={sw}
strokeLinecap="round" strokeDashoffset="none"
strokeLinejoin="round" strokeDasharray="none"
pointerEvents="stroke" strokeLinecap="round"
/> strokeLinejoin="round"
<path pointerEvents="stroke"
d={path} />
fill={isDraw ? styles.stroke : 'none'} )}
stroke={styles.stroke} {endArrowHead && (
strokeWidth={sw} <path
strokeDasharray={strokeDasharray} d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
strokeDashoffset={strokeDashoffset} fill="none"
strokeLinecap="round" stroke={styles.stroke}
strokeLinejoin="round" strokeWidth={sw}
pointerEvents="stroke" strokeDashoffset="none"
/> strokeDasharray="none"
</> strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
/>
)}
</g>
</g>
) )
} }
)
const sw = strokeWidth * 1.618
return (
<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>
)
}
renderIndicator(shape: ArrowShape) { renderIndicator(shape: ArrowShape) {
const path = Utils.getFromCache(this.simplePathCache, shape.handles, () => getArrowPath(shape)) const path = Utils.getFromCache(this.simplePathCache, shape.handles, () => getArrowPath(shape))

View file

@ -1,17 +1,10 @@
import * as React from 'react' import * as React from 'react'
import { TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core' import { TLBounds, Utils, Vec, TLTransformInfo, Intersect, TLShapeProps } from '@tldraw/core'
import getStroke, { getStrokePoints } from 'perfect-freehand' import getStroke, { getStrokePoints } from 'perfect-freehand'
import { defaultStyle, getShapeStyle } from '~shape/shape-styles' import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { import { DrawShape, DashStyle, TLDrawShapeUtil, TLDrawShapeType, TLDrawToolType } from '~types'
DrawShape,
DashStyle,
TLDrawShapeUtil,
TLDrawShapeType,
TLDrawToolType,
TLDrawRenderInfo,
} from '~types'
export class Draw extends TLDrawShapeUtil<DrawShape> { export class Draw extends TLDrawShapeUtil<DrawShape, SVGGElement> {
type = TLDrawShapeType.Draw as const type = TLDrawShapeType.Draw as const
toolType = TLDrawToolType.Draw toolType = TLDrawToolType.Draw
@ -37,118 +30,122 @@ export class Draw extends TLDrawShapeUtil<DrawShape> {
return next.points !== prev.points || next.style !== prev.style return next.points !== prev.points || next.style !== prev.style
} }
render(shape: DrawShape, { meta, isEditing }: TLDrawRenderInfo): JSX.Element { render = React.forwardRef<SVGGElement, TLShapeProps<DrawShape, SVGGElement>>(
const { points, style } = shape ({ shape, meta, events, isEditing }) => {
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 // For very short lines, draw a point instead of a line
const bounds = this.getBounds(shape) 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) { if (!isEditing && verySmall) {
const sw = strokeWidth * 0.618 const sw = strokeWidth * 0.618
return ( return (
<circle <g {...events}>
r={strokeWidth * 0.618} <circle
fill={styles.stroke} r={strokeWidth * 0.618}
stroke={styles.stroke} fill={styles.stroke}
strokeWidth={sw} stroke={styles.stroke}
pointerEvents="all" strokeWidth={sw}
/> pointerEvents="all"
) />
} </g>
)
}
const shouldFill = const shouldFill =
style.isFilled && style.isFilled &&
points.length > 3 && points.length > 3 &&
Vec.dist(points[0], points[points.length - 1]) < +styles.strokeWidth * 2 Vec.dist(points[0], points[points.length - 1]) < +styles.strokeWidth * 2
// For drawn lines, draw a line from the path cache // For drawn lines, draw a line from the path cache
if (shape.style.dash === DashStyle.Draw) { if (shape.style.dash === DashStyle.Draw) {
const polygonPathData = Utils.getFromCache(this.polygonCache, points, () => const polygonPathData = Utils.getFromCache(this.polygonCache, points, () =>
getFillPath(shape) getFillPath(shape)
) )
const drawPathData = isEditing const drawPathData = isEditing
? getDrawStrokePath(shape, true) ? getDrawStrokePath(shape, true)
: Utils.getFromCache(this.drawPathCache, points, () => getDrawStrokePath(shape, false)) : Utils.getFromCache(this.drawPathCache, points, () => getDrawStrokePath(shape, false))
return ( return (
<> <g {...events}>
{shouldFill && ( {shouldFill && (
<path
d={polygonPathData}
stroke="none"
fill={styles.fill}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="fill"
/>
)}
<path <path
d={polygonPathData} d={drawPathData}
stroke="none" fill={styles.stroke}
fill={styles.fill} stroke={styles.stroke}
strokeWidth={strokeWidth}
strokeLinejoin="round" strokeLinejoin="round"
strokeLinecap="round" strokeLinecap="round"
pointerEvents="fill" pointerEvents="all"
/> />
)} </g>
)
}
// 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
return (
<g {...events}>
<path <path
d={drawPathData} d={path}
fill={styles.stroke} fill={shouldFill ? styles.fill : 'none'}
stroke={styles.stroke} stroke="transparent"
strokeWidth={strokeWidth} strokeWidth={Math.min(4, strokeWidth * 2)}
strokeLinejoin="round" strokeLinejoin="round"
strokeLinecap="round" strokeLinecap="round"
pointerEvents="all" pointerEvents={shouldFill ? 'all' : 'stroke'}
/> />
</> <path
d={path}
fill="transparent"
stroke={styles.stroke}
strokeWidth={sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="stroke"
/>
</g>
) )
} }
)
// 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
return (
<>
<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"
/>
</>
)
}
renderIndicator(shape: DrawShape): JSX.Element { renderIndicator(shape: DrawShape): JSX.Element {
const { points } = shape const { points } = shape

View file

@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { Utils, TLTransformInfo, TLBounds, Intersect, Vec } from '@tldraw/core' import { Utils, TLTransformInfo, TLBounds, Intersect, TLShapeProps, Vec } from '@tldraw/core'
import { import {
ArrowShape, ArrowShape,
DashStyle, DashStyle,
@ -15,7 +15,7 @@ import getStroke from 'perfect-freehand'
// TODO // TODO
// [ ] Improve indicator shape for drawn shapes // [ ] Improve indicator shape for drawn shapes
export class Ellipse extends TLDrawShapeUtil<EllipseShape> { export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGGElement> {
type = TLDrawShapeType.Ellipse as const type = TLDrawShapeType.Ellipse as const
toolType = TLDrawToolType.Bounds toolType = TLDrawToolType.Bounds
pathCache = new WeakMap<EllipseShape, string>([]) pathCache = new WeakMap<EllipseShape, string>([])
@ -37,32 +37,79 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape> {
return next.radius !== prev.radius || next.style !== prev.style return next.radius !== prev.radius || next.style !== prev.style
} }
render(shape: EllipseShape, { meta, isBinding }: TLDrawRenderInfo) { render = React.forwardRef<SVGGElement, TLShapeProps<EllipseShape, SVGGElement>>(
const { ({ shape, meta, isBinding, events }) => {
radius: [radiusX, radiusY], const {
style, radius: [radiusX, radiusY],
} = shape style,
} = shape
const styles = getShapeStyle(style, meta.isDarkMode) const styles = getShapeStyle(style, meta.isDarkMode)
const strokeWidth = +styles.strokeWidth const strokeWidth = +styles.strokeWidth
const rx = Math.max(0, radiusX - strokeWidth / 2) const rx = Math.max(0, radiusX - strokeWidth / 2)
const ry = Math.max(0, radiusY - strokeWidth / 2) const ry = Math.max(0, radiusY - strokeWidth / 2)
if (style.dash === DashStyle.Draw) { if (style.dash === DashStyle.Draw) {
const path = Utils.getFromCache(this.pathCache, shape, () => const path = Utils.getFromCache(this.pathCache, shape, () =>
renderPath(shape, this.getCenter(shape)) renderPath(shape, this.getCenter(shape))
)
return (
<g {...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"
/>
</g>
)
}
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 ( return (
<> <g {...events}>
{isBinding && ( {isBinding && (
<ellipse <ellipse
className="tl-binding-indicator" className="tl-binding-indicator"
cx={radiusX} cx={radiusX}
cy={radiusY} cy={radiusY}
rx={rx + 2} rx={rx + 32}
ry={ry + 2} ry={ry + 32}
/> />
)} )}
<ellipse <ellipse
@ -70,64 +117,19 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape> {
cy={radiusY} cy={radiusY}
rx={rx} rx={rx}
ry={ry} ry={ry}
stroke="none" fill={styles.fill}
fill={style.isFilled ? styles.fill : 'none'}
pointerEvents="all"
/>
<path
d={path}
fill={styles.stroke}
stroke={styles.stroke} stroke={styles.stroke}
strokeWidth={strokeWidth} strokeWidth={sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
pointerEvents="all" pointerEvents="all"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
</> </g>
) )
} }
)
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 (
<>
{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"
/>
</>
)
}
renderIndicator(shape: EllipseShape) { renderIndicator(shape: EllipseShape) {
const { const {

View file

@ -1,12 +1,11 @@
import * as React from 'react' import * as React from 'react'
import { TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core' import { TLBounds, Utils, Vec, Intersect, TLShapeProps } from '@tldraw/core'
import { defaultStyle, getPerfectDashProps } from '~shape/shape-styles' import { defaultStyle, getPerfectDashProps } from '~shape/shape-styles'
import { import {
GroupShape, GroupShape,
TLDrawShapeUtil, TLDrawShapeUtil,
TLDrawShapeType, TLDrawShapeType,
TLDrawToolType, TLDrawToolType,
TLDrawRenderInfo,
ColorStyle, ColorStyle,
DashStyle, DashStyle,
ArrowShape, ArrowShape,
@ -15,7 +14,7 @@ import {
// TODO // TODO
// [ ] - Find bounds based on common bounds of descendants // [ ] - Find bounds based on common bounds of descendants
export class Group extends TLDrawShapeUtil<GroupShape> { export class Group extends TLDrawShapeUtil<GroupShape, SVGGElement> {
type = TLDrawShapeType.Group as const type = TLDrawShapeType.Group as const
toolType = TLDrawToolType.Bounds toolType = TLDrawToolType.Bounds
canBind = true canBind = true
@ -39,59 +38,68 @@ export class Group extends TLDrawShapeUtil<GroupShape> {
return next.size !== prev.size || next.style !== prev.style return next.size !== prev.size || next.style !== prev.style
} }
render(shape: GroupShape, { isBinding, isHovered, isSelected }: TLDrawRenderInfo) { render = React.forwardRef<SVGGElement, TLShapeProps<GroupShape, SVGGElement>>(
const { id, size } = shape ({ shape, isBinding, isHovered, isSelected, events }) => {
const { id, size } = shape
const sw = 2 const sw = 2
const w = Math.max(0, size[0] - sw / 2) const w = Math.max(0, size[0] - sw / 2)
const h = Math.max(0, size[1] - sw / 2) const h = Math.max(0, size[1] - sw / 2)
const strokes: [number[], number[], number][] = [ const strokes: [number[], number[], number][] = [
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2], [[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
[[w, sw / 2], [w, h], h - sw / 2], [[w, sw / 2], [w, h], h - sw / 2],
[[w, h], [sw / 2, h], w - sw / 2], [[w, h], [sw / 2, h], w - sw / 2],
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2], [[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
] ]
const paths = strokes.map(([start, end, length], i) => { const paths = strokes.map(([start, end, length], i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
length, length,
sw, sw,
DashStyle.Dotted 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}
/>
)
})
return ( return (
<line <g {...events}>
key={id + '_' + i} {isBinding && (
x1={start[0]} <rect
y1={start[1]} className="tl-binding-indicator"
x2={end[0]} x={-32}
y2={end[1]} y={-32}
stroke={ColorStyle.Black} width={size[0] + 64}
strokeWidth={isHovered || isSelected ? sw : 0} height={size[1] + 64}
strokeLinecap="round" />
strokeDasharray={strokeDasharray} )}
strokeDashoffset={strokeDashoffset}
/>
)
})
return (
<>
{isBinding && (
<rect <rect
className="tl-binding-indicator" x={0}
x={-32} y={0}
y={-32} width={size[0]}
width={size[0] + 64} height={size[1]}
height={size[1] + 64} fill="transparent"
pointerEvents="all"
/> />
)} <g pointerEvents="stroke">{paths}</g>
<rect x={0} y={0} width={size[0]} height={size[1]} fill="transparent" pointerEvents="all" /> </g>
<g pointerEvents="stroke">{paths}</g> )
</> }
) )
}
renderIndicator(shape: GroupShape) { renderIndicator(shape: GroupShape) {
const [width, height] = shape.size const [width, height] = shape.size

View file

@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core' import { TLBounds, Utils, Vec, TLTransformInfo, Intersect, TLShapeProps } from '@tldraw/core'
import getStroke from 'perfect-freehand' import getStroke from 'perfect-freehand'
import { getPerfectDashProps, defaultStyle, getShapeStyle } from '~shape/shape-styles' import { getPerfectDashProps, defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { import {
@ -8,18 +8,16 @@ import {
TLDrawShapeUtil, TLDrawShapeUtil,
TLDrawShapeType, TLDrawShapeType,
TLDrawToolType, TLDrawToolType,
TLDrawRenderInfo,
ArrowShape, ArrowShape,
} from '~types' } from '~types'
// TODO // TODO
// [ ] - Make sure that fill does not extend drawn shape at corners // [ ] - Make sure that fill does not extend drawn shape at corners
export class Rectangle extends TLDrawShapeUtil<RectangleShape> { export class Rectangle extends TLDrawShapeUtil<RectangleShape, SVGGElement> {
type = TLDrawShapeType.Rectangle as const type = TLDrawShapeType.Rectangle as const
toolType = TLDrawToolType.Bounds toolType = TLDrawToolType.Bounds
canBind = true canBind = true
pathCache = new WeakMap<number[], string>([]) pathCache = new WeakMap<number[], string>([])
defaultProps: RectangleShape = { defaultProps: RectangleShape = {
@ -38,105 +36,111 @@ export class Rectangle extends TLDrawShapeUtil<RectangleShape> {
return next.size !== prev.size || next.style !== prev.style return next.size !== prev.size || next.style !== prev.style
} }
render(shape: RectangleShape, { isBinding, meta }: TLDrawRenderInfo) { render = React.forwardRef<SVGGElement, TLShapeProps<RectangleShape, SVGGElement>>(
const { id, size, style } = shape ({ shape, isBinding, meta, events }, ref) => {
const styles = getShapeStyle(style, meta.isDarkMode) const { id, size, style } = shape
const strokeWidth = +styles.strokeWidth const styles = getShapeStyle(style, meta.isDarkMode)
const strokeWidth = +styles.strokeWidth
if (style.dash === DashStyle.Draw) { React.useEffect(() => {
const pathData = Utils.getFromCache(this.pathCache, shape.size, () => renderPath(shape)) console.log(this.refMap.get(shape.id))
}, [])
if (style.dash === DashStyle.Draw) {
const pathData = Utils.getFromCache(this.pathCache, shape.size, () => renderPath(shape))
return (
<g 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"
/>
</g>
)
}
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 ( return (
<> <g ref={ref} {...events}>
{isBinding && ( {isBinding && (
<rect <rect
className="tl-binding-indicator" className="tl-binding-indicator"
x={strokeWidth / 2 - 32} x={sw / 2 - 32}
y={strokeWidth / 2 - 32} y={sw / 2 - 32}
width={Math.max(0, size[0] - strokeWidth / 2) + 64} width={w + 64}
height={Math.max(0, size[1] - strokeWidth / 2) + 64} height={h + 64}
/> />
)} )}
<rect <rect
x={+styles.strokeWidth / 2} x={sw / 2}
y={+styles.strokeWidth / 2} y={sw / 2}
width={Math.max(0, size[0] - strokeWidth)} width={w}
height={Math.max(0, size[1] - strokeWidth)} height={h}
fill={style.isFilled ? styles.fill : 'none'} fill={styles.fill}
stroke="none" stroke="transparent"
strokeWidth={sw}
pointerEvents="all" pointerEvents="all"
/> />
<path <g pointerEvents="stroke">{paths}</g>
d={pathData} </g>
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={styles.strokeWidth}
pointerEvents="all"
/>
</>
) )
} }
)
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 (
<>
{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>
</>
)
}
renderIndicator(shape: RectangleShape) { renderIndicator(shape: RectangleShape) {
const { const {
@ -162,6 +166,7 @@ export class Rectangle extends TLDrawShapeUtil<RectangleShape> {
} }
getBounds(shape: RectangleShape) { getBounds(shape: RectangleShape) {
console.log(this.refMap.get(shape.id))
const bounds = Utils.getFromCache(this.boundsCache, shape, () => { const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
const [width, height] = shape.size const [width, height] = shape.size
return { return {

View file

@ -1,14 +1,7 @@
import * as React from 'react' import * as React from 'react'
import { TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core' import { TLBounds, Utils, Vec, TLTransformInfo, Intersect, TLShapeProps } from '@tldraw/core'
import { getShapeStyle, getFontSize, getFontStyle, defaultStyle } from '~shape/shape-styles' import { getShapeStyle, getFontSize, getFontStyle, defaultStyle } from '~shape/shape-styles'
import { import { TextShape, TLDrawShapeUtil, TLDrawShapeType, TLDrawToolType, ArrowShape } from '~types'
TextShape,
TLDrawShapeUtil,
TLDrawShapeType,
TLDrawRenderInfo,
TLDrawToolType,
ArrowShape,
} from '~types'
import styled from '~styles' import styled from '~styles'
import TextAreaUtils from './text-utils' import TextAreaUtils from './text-utils'
@ -56,7 +49,7 @@ if (typeof window !== 'undefined') {
melm = getMeasurementDiv() melm = getMeasurementDiv()
} }
export class Text extends TLDrawShapeUtil<TextShape> { export class Text extends TLDrawShapeUtil<TextShape, SVGGElement> {
type = TLDrawShapeType.Text as const type = TLDrawShapeType.Text as const
toolType = TLDrawToolType.Text toolType = TLDrawToolType.Text
isAspectRatioLocked = true isAspectRatioLocked = true
@ -90,156 +83,144 @@ export class Text extends TLDrawShapeUtil<TextShape> {
) )
} }
render( render = React.forwardRef<SVGGElement, TLShapeProps<TextShape, SVGGElement>>(
shape: TextShape, ({ shape, meta, isEditing, isBinding, events }, ref) => {
{ const rInput = React.useRef<HTMLTextAreaElement>(null)
ref, const { id, text, style } = shape
meta, const styles = getShapeStyle(style, meta.isDarkMode)
isEditing, const font = getFontStyle(shape.style)
isBinding, const bounds = this.getBounds(shape)
onTextBlur,
onTextChange,
onTextFocus,
onTextKeyDown,
onTextKeyUp,
}: TLDrawRenderInfo
): JSX.Element {
const { id, text, style } = shape
const styles = getShapeStyle(style, meta.isDarkMode)
const font = getFontStyle(shape.style)
const bounds = this.getBounds(shape)
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) { function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
onTextChange?.(id, normalizeText(e.currentTarget.value)) events.onTextChange?.(id, normalizeText(e.currentTarget.value))
} }
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
onTextKeyDown?.(id, e.key) events.onTextKeyDown?.(id, e.key)
if (e.key === 'Escape') return if (e.key === 'Escape') return
e.stopPropagation() e.stopPropagation()
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault() e.preventDefault()
if (e.shiftKey) { if (e.shiftKey) {
TextAreaUtils.unindent(e.currentTarget) TextAreaUtils.unindent(e.currentTarget)
} else { } else {
TextAreaUtils.indent(e.currentTarget) TextAreaUtils.indent(e.currentTarget)
}
events.onTextChange?.(id, normalizeText(e.currentTarget.value))
}
}
function handleKeyUp(e: React.KeyboardEvent<HTMLTextAreaElement>) {
events.onTextKeyUp?.(id, e.key)
}
function handleBlur(e: React.FocusEvent<HTMLTextAreaElement>) {
if (isEditing) {
e.currentTarget.focus()
e.currentTarget.select()
return
} }
onTextChange?.(id, normalizeText(e.currentTarget.value)) setTimeout(() => {
} events.onTextBlur?.(id)
} }, 0)
function handleKeyUp(e: React.KeyboardEvent<HTMLTextAreaElement>) {
onTextKeyUp?.(id, e.key)
}
function handleBlur(e: React.FocusEvent<HTMLTextAreaElement>) {
if (isEditing) {
e.currentTarget.focus()
e.currentTarget.select()
return
} }
setTimeout(() => { function handleFocus(e: React.FocusEvent<HTMLTextAreaElement>) {
onTextBlur?.(id) if (document.activeElement === e.currentTarget) {
}, 0) e.currentTarget.select()
} events.onTextFocus?.(id)
}
function handleFocus(e: React.FocusEvent<HTMLTextAreaElement>) {
if (document.activeElement === e.currentTarget) {
e.currentTarget.select()
onTextFocus?.(id)
} }
}
function handlePointerDown() { function handlePointerDown() {
if (ref && ref.current.selectionEnd !== 0) { const elm = rInput.current
ref.current.selectionEnd = 0 if (!elm) return
if (elm.selectionEnd !== 0) {
elm.selectionEnd = 0
}
} }
}
const fontSize = getFontSize(shape.style.size) * (shape.style.scale || 1) const fontSize = getFontSize(shape.style.size) * (shape.style.scale || 1)
const lineHeight = fontSize * 1.3 const lineHeight = fontSize * 1.3
if (!isEditing) {
return (
<g ref={ref} {...events}>
{isBinding && (
<rect
className="tl-binding-indicator"
x={-16}
y={-16}
width={bounds.width + 32}
height={bounds.height + 32}
/>
)}
{text.split('\n').map((str, i) => (
<text
key={i}
x={4}
y={4 + fontSize / 2 + i * lineHeight}
fontFamily="Caveat Brush"
fontStyle="normal"
fontWeight="500"
letterSpacing={LETTER_SPACING}
fontSize={fontSize}
width={bounds.width}
height={bounds.height}
fill={styles.stroke}
color={styles.stroke}
stroke="none"
xmlSpace="preserve"
dominantBaseline="mathematical"
alignmentBaseline="mathematical"
>
{str}
</text>
))}
</g>
)
}
if (!isEditing) {
return ( return (
<> <foreignObject
{isBinding && ( width={bounds.width}
<rect height={bounds.height}
className="tl-binding-indicator" pointerEvents="none"
x={-16} onPointerDown={(e) => e.stopPropagation()}
y={-16} >
width={bounds.width + 32} <StyledTextArea
height={bounds.height + 32} ref={rInput}
/> style={{
)} font,
{text.split('\n').map((str, i) => ( color: styles.stroke,
<text }}
key={i} name="text"
x={4} defaultValue={text}
y={4 + fontSize / 2 + i * lineHeight} tabIndex={-1}
fontFamily="Caveat Brush" autoComplete="false"
fontStyle="normal" autoCapitalize="false"
fontWeight="500" autoCorrect="false"
letterSpacing={LETTER_SPACING} autoSave="false"
fontSize={fontSize} placeholder=""
width={bounds.width} color={styles.stroke}
height={bounds.height} autoFocus={true}
fill={styles.stroke} onFocus={handleFocus}
color={styles.stroke} onBlur={handleBlur}
stroke="none" onKeyDown={handleKeyDown}
xmlSpace="preserve" onKeyUp={handleKeyUp}
dominantBaseline="mathematical" onChange={handleChange}
alignmentBaseline="mathematical" onPointerDown={handlePointerDown}
> />
{str} </foreignObject>
</text>
))}
</>
) )
} }
)
if (ref === undefined) {
throw Error('This component should receive a ref when editing.')
}
return (
<foreignObject
width={bounds.width}
height={bounds.height}
pointerEvents="none"
onPointerDown={(e) => e.stopPropagation()}
>
<StyledTextArea
ref={ref as React.RefObject<HTMLTextAreaElement>}
style={{
font,
color: styles.stroke,
}}
name="text"
defaultValue={text}
tabIndex={-1}
autoComplete="false"
autoCapitalize="false"
autoCorrect="false"
autoSave="false"
placeholder=""
color={styles.stroke}
autoFocus={true}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onChange={handleChange}
onPointerDown={handlePointerDown}
/>
</foreignObject>
)
}
renderIndicator(): JSX.Element | null { renderIndicator(): JSX.Element | null {
return null return null

View file

@ -13,7 +13,9 @@ import type {
} from '~types' } from '~types'
export class TLDR { export class TLDR {
static getShapeUtils<T extends TLDrawShape>(shape: T | T['type']): TLDrawShapeUtil<T> { static getShapeUtils<T extends TLDrawShape>(
shape: T | T['type']
): TLDrawShapeUtil<T, HTMLElement | SVGElement> {
return getShapeUtils(typeof shape === 'string' ? ({ type: shape } as T) : shape) return getShapeUtils(typeof shape === 'string' ? ({ type: shape } as T) : shape)
} }

View file

@ -72,7 +72,7 @@ const defaultState: Data = {
settings: { settings: {
isPenMode: false, isPenMode: false,
isDarkMode: false, isDarkMode: false,
isZoomSnap: true, isZoomSnap: false,
isDebugMode: process.env.NODE_ENV === 'development', isDebugMode: process.env.NODE_ENV === 'development',
isReadonlyMode: false, isReadonlyMode: false,
nudgeDistanceLarge: 10, nudgeDistanceLarge: 10,

View file

@ -199,11 +199,17 @@ export type TLDrawShape =
| TextShape | TextShape
| GroupShape | GroupShape
export abstract class TLDrawShapeUtil<T extends TLDrawShape> extends TLShapeUtil<T> { export abstract class TLDrawShapeUtil<
T extends TLDrawShape,
E extends HTMLElement | SVGElement
> extends TLShapeUtil<T, E> {
abstract toolType: TLDrawToolType abstract toolType: TLDrawToolType
} }
export type TLDrawShapeUtils = Record<TLDrawShapeType, TLDrawShapeUtil<TLDrawShape>> export type TLDrawShapeUtils = Record<
TLDrawShapeType,
TLDrawShapeUtil<TLDrawShape, HTMLElement | SVGElement>
>
export interface ArrowBinding extends TLBinding { export interface ArrowBinding extends TLBinding {
type: 'arrow' type: 'arrow'