commit
6e97d67f0d
61 changed files with 1603 additions and 1153 deletions
|
@ -1,7 +1,9 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import type { TLBounds } from '+types'
|
||||
import { Utils } from '+utils'
|
||||
import { useBoundsEvents } from '+hooks'
|
||||
import { useBoundsEvents, usePosition } from '+hooks'
|
||||
import { Container } from '+components/container'
|
||||
import { SVGContainer } from '+components/svg-container'
|
||||
|
||||
interface BoundsBgProps {
|
||||
bounds: TLBounds
|
||||
|
@ -11,20 +13,11 @@ interface BoundsBgProps {
|
|||
export function BoundsBg({ bounds, rotation }: BoundsBgProps): JSX.Element {
|
||||
const events = useBoundsEvents()
|
||||
|
||||
const { width, height } = bounds
|
||||
|
||||
const center = Utils.getBoundsCenter(bounds)
|
||||
|
||||
return (
|
||||
<rect
|
||||
className="tl-bounds-bg"
|
||||
width={Math.max(1, width)}
|
||||
height={Math.max(1, height)}
|
||||
transform={`
|
||||
rotate(${rotation * (180 / Math.PI)},${center})
|
||||
translate(${bounds.minX},${bounds.minY})
|
||||
rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`}
|
||||
{...events}
|
||||
/>
|
||||
<Container bounds={bounds} rotation={rotation}>
|
||||
<SVGContainer>
|
||||
<rect className="tl-bounds-bg" width={bounds.width} height={bounds.height} {...events} />
|
||||
</SVGContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import { TLBoundsEdge, TLBoundsCorner, TLBounds } from '+types'
|
||||
import { Utils } from '+utils'
|
||||
import { CenterHandle } from './center-handle'
|
||||
import { RotateHandle } from './rotate-handle'
|
||||
import { CornerHandle } from './corner-handle'
|
||||
import { EdgeHandle } from './edge-handle'
|
||||
import { Container } from '+components/container'
|
||||
import { SVGContainer } from '+components/svg-container'
|
||||
|
||||
interface BoundsProps {
|
||||
zoom: number
|
||||
|
@ -23,65 +25,65 @@ export function Bounds({
|
|||
}: BoundsProps): JSX.Element {
|
||||
const targetSize = (viewportWidth < 768 ? 16 : 8) / zoom // Touch target size
|
||||
const size = 8 / zoom // Touch target size
|
||||
const center = Utils.getBoundsCenter(bounds)
|
||||
|
||||
return (
|
||||
<g
|
||||
pointerEvents="all"
|
||||
transform={`
|
||||
rotate(${rotation * (180 / Math.PI)},${center})
|
||||
translate(${bounds.minX},${bounds.minY})
|
||||
rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`}
|
||||
>
|
||||
<CenterHandle bounds={bounds} isLocked={isLocked} />
|
||||
{!isLocked && (
|
||||
<>
|
||||
<EdgeHandle targetSize={targetSize} size={size} bounds={bounds} edge={TLBoundsEdge.Top} />
|
||||
<EdgeHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
edge={TLBoundsEdge.Right}
|
||||
/>
|
||||
<EdgeHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
edge={TLBoundsEdge.Bottom}
|
||||
/>
|
||||
<EdgeHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
edge={TLBoundsEdge.Left}
|
||||
/>
|
||||
<CornerHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
corner={TLBoundsCorner.TopLeft}
|
||||
/>
|
||||
<CornerHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
corner={TLBoundsCorner.TopRight}
|
||||
/>
|
||||
<CornerHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
corner={TLBoundsCorner.BottomRight}
|
||||
/>
|
||||
<CornerHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
corner={TLBoundsCorner.BottomLeft}
|
||||
/>
|
||||
<RotateHandle targetSize={targetSize} size={size} bounds={bounds} />
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
<Container bounds={bounds} rotation={rotation}>
|
||||
<SVGContainer>
|
||||
<CenterHandle bounds={bounds} isLocked={isLocked} />
|
||||
{!isLocked && (
|
||||
<>
|
||||
<EdgeHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
edge={TLBoundsEdge.Top}
|
||||
/>
|
||||
<EdgeHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
edge={TLBoundsEdge.Right}
|
||||
/>
|
||||
<EdgeHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
edge={TLBoundsEdge.Bottom}
|
||||
/>
|
||||
<EdgeHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
edge={TLBoundsEdge.Left}
|
||||
/>
|
||||
<CornerHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
corner={TLBoundsCorner.TopLeft}
|
||||
/>
|
||||
<CornerHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
corner={TLBoundsCorner.TopRight}
|
||||
/>
|
||||
<CornerHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
corner={TLBoundsCorner.BottomRight}
|
||||
/>
|
||||
<CornerHandle
|
||||
targetSize={targetSize}
|
||||
size={size}
|
||||
bounds={bounds}
|
||||
corner={TLBoundsCorner.BottomLeft}
|
||||
/>
|
||||
<RotateHandle targetSize={targetSize} size={size} bounds={bounds} />
|
||||
</>
|
||||
)}
|
||||
</SVGContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,10 +3,10 @@ import { useBoundsHandleEvents } from '+hooks'
|
|||
import { TLBoundsEdge, TLBounds } from '+types'
|
||||
|
||||
const edgeClassnames = {
|
||||
[TLBoundsEdge.Top]: 'tl-transparent tl-cursor-ns',
|
||||
[TLBoundsEdge.Right]: 'tl-transparent tl-cursor-ew',
|
||||
[TLBoundsEdge.Bottom]: 'tl-transparent tl-cursor-ns',
|
||||
[TLBoundsEdge.Left]: 'tl-transparent tl-cursor-ew',
|
||||
[TLBoundsEdge.Top]: 'tl-transparent tl-edge-handle tl-cursor-ns',
|
||||
[TLBoundsEdge.Right]: 'tl-transparent tl-edge-handle tl-cursor-ew',
|
||||
[TLBoundsEdge.Bottom]: 'tl-transparent tl-edge-handle tl-cursor-ns',
|
||||
[TLBoundsEdge.Left]: 'tl-transparent tl-edge-handle tl-cursor-ew',
|
||||
}
|
||||
|
||||
interface EdgeHandleProps {
|
||||
|
@ -26,6 +26,7 @@ export const EdgeHandle = React.memo(({ size, bounds, edge }: EdgeHandleProps):
|
|||
|
||||
return (
|
||||
<rect
|
||||
pointerEvents="all"
|
||||
className={edgeClassnames[edge]}
|
||||
x={isHorizontal ? size / 2 : (isFarEdge ? width + 1 : -1) - size / 2}
|
||||
y={isHorizontal ? (isFarEdge ? height + 1 : -1) - size / 2 : size / 2}
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as React from 'react'
|
|||
import type { TLBounds } from '+types'
|
||||
|
||||
export class BrushUpdater {
|
||||
ref = React.createRef<SVGRectElement>()
|
||||
ref = React.createRef<SVGSVGElement>()
|
||||
|
||||
isControlled = false
|
||||
|
||||
|
@ -18,8 +18,7 @@ export class BrushUpdater {
|
|||
if (!elm) return
|
||||
|
||||
elm.setAttribute('opacity', '1')
|
||||
elm.setAttribute('x', bounds.minX.toString())
|
||||
elm.setAttribute('y', bounds.minY.toString())
|
||||
elm.setAttribute('transform', `translate(${bounds.minX.toString()}, ${bounds.minY.toString()})`)
|
||||
elm.setAttribute('width', bounds.width.toString())
|
||||
elm.setAttribute('height', bounds.height.toString())
|
||||
}
|
||||
|
|
|
@ -4,5 +4,9 @@ import { BrushUpdater } from './BrushUpdater'
|
|||
export const brushUpdater = new BrushUpdater()
|
||||
|
||||
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>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as React from 'react'
|
||||
import {
|
||||
usePreventNavigation,
|
||||
|
@ -10,7 +11,6 @@ import type { TLBinding, TLPage, TLPageState, TLShape } from '+types'
|
|||
import { ErrorFallback } from '+components/error-fallback'
|
||||
import { ErrorBoundary } from '+components/error-boundary'
|
||||
import { Brush } from '+components/brush'
|
||||
import { Defs } from '+components/defs'
|
||||
import { Page } from '+components/page'
|
||||
import { useResizeObserver } from '+hooks/useResizeObserver'
|
||||
|
||||
|
@ -18,24 +18,24 @@ function resetError() {
|
|||
void null
|
||||
}
|
||||
|
||||
interface CanvasProps<T extends TLShape> {
|
||||
interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
|
||||
page: TLPage<T, TLBinding>
|
||||
pageState: TLPageState
|
||||
hideBounds?: boolean
|
||||
hideHandles?: 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,
|
||||
pageState,
|
||||
meta,
|
||||
hideHandles = false,
|
||||
hideBounds = false,
|
||||
hideIndicators = false,
|
||||
}: CanvasProps<T>): JSX.Element {
|
||||
const rCanvas = React.useRef<SVGSVGElement>(null)
|
||||
}: CanvasProps<T, M>): JSX.Element {
|
||||
const rCanvas = React.useRef<HTMLDivElement>(null)
|
||||
const rContainer = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
useResizeObserver(rCanvas)
|
||||
|
@ -48,14 +48,23 @@ export function Canvas<T extends TLShape>({
|
|||
|
||||
const events = useCanvasEvents()
|
||||
|
||||
const rGroup = useCameraCss(rContainer, pageState)
|
||||
const rLayer = useCameraCss(rContainer, pageState)
|
||||
|
||||
const preventScrolling = React.useCallback((e: React.UIEvent<HTMLDivElement, UIEvent>) => {
|
||||
e.currentTarget.scrollTo(0, 0)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<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}
|
||||
onScroll={preventScrolling}
|
||||
{...events}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={resetError}>
|
||||
<Defs zoom={pageState.camera.zoom} />
|
||||
<g ref={rGroup} id="tl-shapes">
|
||||
<div ref={rLayer} className="tl-absolute tl-layer">
|
||||
<Page
|
||||
page={page}
|
||||
pageState={pageState}
|
||||
|
@ -65,9 +74,9 @@ export function Canvas<T extends TLShape>({
|
|||
meta={meta}
|
||||
/>
|
||||
<Brush />
|
||||
</g>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
23
packages/core/src/components/container/container.tsx
Normal file
23
packages/core/src/components/container/container.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
1
packages/core/src/components/container/index.ts
Normal file
1
packages/core/src/components/container/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './container'
|
|
@ -1,24 +1,41 @@
|
|||
import * as React from 'react'
|
||||
import { useHandleEvents } from '+hooks'
|
||||
import { Container } from '+components/container'
|
||||
import Utils from '+utils'
|
||||
import { SVGContainer } from '+components/svg-container'
|
||||
|
||||
interface HandleProps {
|
||||
id: string
|
||||
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 bounds = React.useMemo(
|
||||
() =>
|
||||
Utils.translateBounds(
|
||||
{
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 32,
|
||||
maxY: 32,
|
||||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
point
|
||||
),
|
||||
[point]
|
||||
)
|
||||
|
||||
return (
|
||||
<g className="tl-handles" transform={`translate(${point})`} {...events}>
|
||||
<circle
|
||||
id="handle-bg"
|
||||
className="tl-handle-bg"
|
||||
pointerEvents="all"
|
||||
r={20 / Math.max(1, zoom)}
|
||||
/>
|
||||
<use href="#handle" />
|
||||
</g>
|
||||
<Container bounds={bounds}>
|
||||
<SVGContainer>
|
||||
<g className="tl-handles" {...events}>
|
||||
<circle className="tl-handle-bg" pointerEvents="all" />
|
||||
<circle className="tl-counter-scaled tl-handle" pointerEvents="none" r={4} />
|
||||
</g>
|
||||
</SVGContainer>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -4,6 +4,6 @@ import { Handles } from './handles'
|
|||
|
||||
describe('handles', () => {
|
||||
test('mounts component without crashing', () => {
|
||||
renderWithContext(<Handles zoom={1} shape={mockUtils.box.create({})} />)
|
||||
renderWithContext(<Handles shape={mockUtils.box.create({ id: 'box' })} />)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,35 +1,26 @@
|
|||
import * as React from 'react'
|
||||
import { Vec } from '+utils'
|
||||
import type { TLShape } from '+types'
|
||||
import { useTLContext } from '+hooks'
|
||||
import { Handle } from './handle'
|
||||
|
||||
interface HandlesProps {
|
||||
shape: TLShape
|
||||
zoom: number
|
||||
}
|
||||
|
||||
const toAngle = 180 / Math.PI
|
||||
|
||||
export const Handles = React.memo(({ shape, zoom }: HandlesProps): JSX.Element | null => {
|
||||
const { shapeUtils } = useTLContext()
|
||||
|
||||
const center = shapeUtils[shape.type].getCenter(shape)
|
||||
|
||||
export const Handles = React.memo(({ shape }: HandlesProps): JSX.Element | null => {
|
||||
if (shape.handles === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={`rotate(${(shape.rotation || 0) * toAngle},${center})`}>
|
||||
<>
|
||||
{Object.values(shape.handles).map((handle) => (
|
||||
<Handle
|
||||
key={shape.id + '_' + handle.id}
|
||||
id={handle.id}
|
||||
point={Vec.add(handle.point, shape.point)}
|
||||
zoom={zoom}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import * as React from 'react'
|
||||
|
||||
interface HTMLContainerProps extends React.HTMLProps<HTMLDivElement> {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const HTMLContainer = React.memo(
|
||||
React.forwardRef<HTMLDivElement, HTMLContainerProps>(({ children, ...rest }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="tl-positioned-div" {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)
|
1
packages/core/src/components/html-container/index.ts
Normal file
1
packages/core/src/components/html-container/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './html-container'
|
|
@ -1,2 +1,4 @@
|
|||
export * from './renderer'
|
||||
export { brushUpdater } from './brush'
|
||||
export * from './svg-container'
|
||||
export * from './html-container'
|
||||
|
|
|
@ -1,35 +1,43 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as React from 'react'
|
||||
import type { TLBinding, TLPage, TLPageState, TLShape } from '+types'
|
||||
import { useSelection, useShapeTree, useHandles, useRenderOnResize, useTLContext } from '+hooks'
|
||||
import { useSelection, useShapeTree, useHandles, useTLContext } from '+hooks'
|
||||
import { Bounds } from '+components/bounds'
|
||||
import { BoundsBg } from '+components/bounds/bounds-bg'
|
||||
import { Handles } from '+components/handles'
|
||||
import { ShapeNode } from '+components/shape'
|
||||
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>
|
||||
pageState: TLPageState
|
||||
hideBounds: boolean
|
||||
hideHandles: boolean
|
||||
hideIndicators: boolean
|
||||
meta?: Record<string, unknown>
|
||||
meta?: M
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
pageState,
|
||||
hideBounds,
|
||||
hideHandles,
|
||||
hideIndicators,
|
||||
meta,
|
||||
}: PageProps<T>): JSX.Element {
|
||||
}: PageProps<T, M>): JSX.Element {
|
||||
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.onRenderCountChange
|
||||
)
|
||||
|
||||
const { shapeWithHandles } = useHandles(page, pageState)
|
||||
|
||||
|
@ -69,7 +77,7 @@ export function Page<T extends TLShape>({
|
|||
variant="hovered"
|
||||
/>
|
||||
)}
|
||||
{!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} zoom={zoom} />}
|
||||
{!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -13,12 +13,15 @@ import { Canvas } from '../canvas'
|
|||
import { Inputs } from '../../inputs'
|
||||
import { useTLTheme, TLContext, TLContextType } from '../../hooks'
|
||||
|
||||
export interface RendererProps<T extends TLShape, M extends Record<string, unknown>>
|
||||
extends Partial<TLCallbacks> {
|
||||
export interface RendererProps<
|
||||
T extends TLShape,
|
||||
E extends Element,
|
||||
M extends Record<string, unknown>
|
||||
> extends Partial<TLCallbacks<T>> {
|
||||
/**
|
||||
* An object containing instances of your shape classes.
|
||||
*/
|
||||
shapeUtils: TLShapeUtils<T>
|
||||
shapeUtils: TLShapeUtils<T, E>
|
||||
/**
|
||||
* The current page, containing shapes and bindings.
|
||||
*/
|
||||
|
@ -63,7 +66,7 @@ export interface RendererProps<T extends TLShape, M extends Record<string, unkno
|
|||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
|
||||
export function Renderer<T extends TLShape, E extends Element, M extends Record<string, unknown>>({
|
||||
shapeUtils,
|
||||
page,
|
||||
pageState,
|
||||
|
@ -73,17 +76,20 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
|
|||
hideIndicators = false,
|
||||
hideBounds = false,
|
||||
...rest
|
||||
}: RendererProps<T, M>): JSX.Element {
|
||||
}: RendererProps<T, E, M>): JSX.Element {
|
||||
useTLTheme(theme)
|
||||
|
||||
const rScreenBounds = React.useRef<TLBounds>(null)
|
||||
|
||||
const rPageState = React.useRef<TLPageState>(pageState)
|
||||
|
||||
React.useEffect(() => {
|
||||
rPageState.current = pageState
|
||||
}, [pageState])
|
||||
|
||||
const [context] = React.useState<TLContextType>(() => ({
|
||||
rest
|
||||
|
||||
const [context] = React.useState<TLContextType<T, E>>(() => ({
|
||||
callbacks: rest,
|
||||
shapeUtils,
|
||||
rScreenBounds,
|
||||
|
@ -92,7 +98,7 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
|
|||
}))
|
||||
|
||||
return (
|
||||
<TLContext.Provider value={context}>
|
||||
<TLContext.Provider value={context as unknown as TLContextType<TLShape, Element>}>
|
||||
<Canvas
|
||||
page={page}
|
||||
pageState={pageState}
|
||||
|
|
|
@ -4,6 +4,8 @@ import { ShapeIndicator } from './shape-indicator'
|
|||
|
||||
describe('shape indicator', () => {
|
||||
test('mounts component without crashing', () => {
|
||||
renderWithSvg(<ShapeIndicator shape={mockUtils.box.create({})} variant={'selected'} />)
|
||||
renderWithSvg(
|
||||
<ShapeIndicator shape={mockUtils.box.create({ id: 'box1' })} variant={'selected'} />
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,20 +1,25 @@
|
|||
import * as React from 'react'
|
||||
import type { TLShape } from '+types'
|
||||
import { useTLContext } from '+hooks'
|
||||
import { usePosition, useTLContext } from '+hooks'
|
||||
|
||||
export const ShapeIndicator = React.memo(
|
||||
({ shape, variant }: { shape: TLShape; variant: 'selected' | 'hovered' }) => {
|
||||
const { shapeUtils } = useTLContext()
|
||||
const utils = shapeUtils[shape.type]
|
||||
|
||||
const center = utils.getCenter(shape)
|
||||
const rotation = (shape.rotation || 0) * (180 / Math.PI)
|
||||
const transform = `rotate(${rotation}, ${center}) translate(${shape.point})`
|
||||
const bounds = utils.getBounds(shape)
|
||||
const rBounds = usePosition(bounds, shape.rotation)
|
||||
|
||||
return (
|
||||
<g className={variant === 'selected' ? 'tl-selected' : 'tl-hovered'} transform={transform}>
|
||||
{shapeUtils[shape.type].renderIndicator(shape)}
|
||||
</g>
|
||||
<div
|
||||
ref={rBounds}
|
||||
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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
import { useTLContext } from '+hooks'
|
||||
import * as React from 'react'
|
||||
import type { TLShapeUtil, TLRenderInfo, TLShape } from '+types'
|
||||
|
||||
interface EditingShapeProps<T extends TLShape> extends TLRenderInfo {
|
||||
shape: T
|
||||
utils: TLShapeUtil<T>
|
||||
}
|
||||
|
||||
export function EditingTextShape({
|
||||
shape,
|
||||
utils,
|
||||
isEditing,
|
||||
isBinding,
|
||||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
}: EditingShapeProps<TLShape>) {
|
||||
const {
|
||||
callbacks: { onTextChange, onTextBlur, onTextFocus, onTextKeyDown, onTextKeyUp },
|
||||
} = useTLContext()
|
||||
|
||||
const ref = React.useRef<HTMLElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Firefox fix?
|
||||
setTimeout(() => {
|
||||
if (document.activeElement !== ref.current) {
|
||||
ref.current?.focus()
|
||||
}
|
||||
}, 0)
|
||||
}, [shape.id])
|
||||
|
||||
return utils.render(shape, {
|
||||
ref,
|
||||
isEditing,
|
||||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
isBinding,
|
||||
onTextChange,
|
||||
onTextBlur,
|
||||
onTextFocus,
|
||||
onTextKeyDown,
|
||||
onTextKeyUp,
|
||||
meta,
|
||||
})
|
||||
}
|
|
@ -1,13 +1,9 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import type { TLShapeUtil, TLRenderInfo, TLShape } from '+types'
|
||||
|
||||
interface RenderedShapeProps<T extends TLShape> extends TLRenderInfo {
|
||||
shape: T
|
||||
utils: TLShapeUtil<T>
|
||||
}
|
||||
|
||||
export const RenderedShape = React.memo(
|
||||
function RenderedShape({
|
||||
<T extends TLShape, E extends Element, M extends Record<string, unknown>>({
|
||||
shape,
|
||||
utils,
|
||||
isEditing,
|
||||
|
@ -15,16 +11,31 @@ export const RenderedShape = React.memo(
|
|||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
events,
|
||||
meta,
|
||||
}: RenderedShapeProps<TLShape>) {
|
||||
return utils.render(shape, {
|
||||
isEditing,
|
||||
isBinding,
|
||||
isHovered,
|
||||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
})
|
||||
}: TLRenderInfo<T, M, E> & {
|
||||
shape: T
|
||||
utils: TLShapeUtil<T, E>
|
||||
}) => {
|
||||
const ref = utils.getRef(shape)
|
||||
|
||||
return (
|
||||
<utils.render
|
||||
ref={ref}
|
||||
shape={shape}
|
||||
isEditing={isEditing}
|
||||
isBinding={isBinding}
|
||||
isHovered={isHovered}
|
||||
isSelected={isSelected}
|
||||
isCurrentParent={isCurrentParent}
|
||||
meta={meta}
|
||||
events={events}
|
||||
onShapeChange={onShapeChange}
|
||||
onShapeBlur={onShapeBlur}
|
||||
/>
|
||||
)
|
||||
},
|
||||
(prev, next) => {
|
||||
// If these have changed, then definitely render
|
||||
|
|
|
@ -3,7 +3,7 @@ import type { IShapeTreeNode, TLShape, TLShapeUtils } from '+types'
|
|||
import { Shape } from './shape'
|
||||
|
||||
export const ShapeNode = React.memo(
|
||||
<M extends Record<string, unknown>>({
|
||||
({
|
||||
shape,
|
||||
utils,
|
||||
children,
|
||||
|
@ -13,7 +13,7 @@ export const ShapeNode = React.memo(
|
|||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
}: { utils: TLShapeUtils<TLShape> } & IShapeTreeNode<M>) => {
|
||||
}: { utils: TLShapeUtils<TLShape, Element> } & IShapeTreeNode<TLShape, any>) => {
|
||||
return (
|
||||
<>
|
||||
<Shape
|
||||
|
|
|
@ -6,7 +6,7 @@ describe('shape', () => {
|
|||
test('mounts component without crashing', () => {
|
||||
renderWithSvg(
|
||||
<Shape
|
||||
shape={mockUtils.box.create({})}
|
||||
shape={mockUtils.box.create({ id: 'box' })}
|
||||
utils={mockUtils[mockUtils.box.type]}
|
||||
isEditing={false}
|
||||
isBinding={false}
|
||||
|
@ -17,3 +17,6 @@ describe('shape', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
// { shape: TLShape; ref: ForwardedRef<Element>; } & TLRenderInfo<any, any> & RefAttributes<Element>
|
||||
// { shape: BoxShape; ref: ForwardedRef<any>; } & TLRenderInfo<any, any> & RefAttributes<any>'
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import { useShapeEvents } from '+hooks'
|
||||
import type { IShapeTreeNode, TLShape, TLShapeUtil } from '+types'
|
||||
import { RenderedShape } from './rendered-shape'
|
||||
import { EditingTextShape } from './editing-text-shape'
|
||||
import { Container } from '+components/container'
|
||||
import { useTLContext } from '+hooks'
|
||||
|
||||
export const Shape = <M extends Record<string, unknown>>({
|
||||
export const Shape = <T extends TLShape, E extends Element, M extends Record<string, unknown>>({
|
||||
shape,
|
||||
utils,
|
||||
isEditing,
|
||||
|
@ -13,42 +16,32 @@ export const Shape = <M extends Record<string, unknown>>({
|
|||
isSelected,
|
||||
isCurrentParent,
|
||||
meta,
|
||||
}: { utils: TLShapeUtil<TLShape> } & IShapeTreeNode<M>) => {
|
||||
}: IShapeTreeNode<T, M> & {
|
||||
utils: TLShapeUtil<T, E>
|
||||
}) => {
|
||||
const { callbacks } = useTLContext()
|
||||
const bounds = utils.getBounds(shape)
|
||||
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 (
|
||||
<g
|
||||
className={isCurrentParent ? 'tl-shape-group tl-current-parent' : 'tl-shape-group'}
|
||||
<Container
|
||||
id={shape.id}
|
||||
transform={transform}
|
||||
{...events}
|
||||
className={'tl-shape' + (isCurrentParent ? 'tl-current-parent' : '')}
|
||||
bounds={bounds}
|
||||
rotation={shape.rotation}
|
||||
>
|
||||
{isEditing && utils.isEditableText ? (
|
||||
<EditingTextShape
|
||||
shape={shape}
|
||||
isBinding={false}
|
||||
isCurrentParent={false}
|
||||
isEditing={true}
|
||||
isHovered={isHovered}
|
||||
isSelected={isSelected}
|
||||
utils={utils}
|
||||
meta={meta}
|
||||
/>
|
||||
) : (
|
||||
<RenderedShape
|
||||
shape={shape}
|
||||
utils={utils}
|
||||
isBinding={isBinding}
|
||||
isCurrentParent={isCurrentParent}
|
||||
isEditing={isEditing}
|
||||
isHovered={isHovered}
|
||||
isSelected={isSelected}
|
||||
meta={meta}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
<RenderedShape
|
||||
shape={shape}
|
||||
isBinding={isBinding}
|
||||
isCurrentParent={isCurrentParent}
|
||||
isEditing={isEditing}
|
||||
isHovered={isHovered}
|
||||
isSelected={isSelected}
|
||||
utils={utils as any}
|
||||
meta={meta as any}
|
||||
events={events}
|
||||
onShapeChange={callbacks.onShapeChange}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
|
1
packages/core/src/components/svg-container/index.ts
Normal file
1
packages/core/src/components/svg-container/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './svg-container'
|
15
packages/core/src/components/svg-container/svg-container.tsx
Normal file
15
packages/core/src/components/svg-container/svg-container.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import * as React from 'react'
|
||||
|
||||
interface SvgContainerProps extends React.SVGProps<SVGSVGElement> {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const SVGContainer = React.memo(
|
||||
React.forwardRef<SVGSVGElement, SvgContainerProps>(({ children, ...rest }, ref) => {
|
||||
return (
|
||||
<svg ref={ref} className="tl-positioned-svg" {...rest}>
|
||||
<g className="tl-centered-g">{children}</g>
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
)
|
|
@ -8,9 +8,9 @@ export * from './useStyle'
|
|||
export * from './useCanvasEvents'
|
||||
export * from './useBoundsHandleEvents'
|
||||
export * from './useCameraCss'
|
||||
export * from './useRenderOnResize'
|
||||
export * from './useSelection'
|
||||
export * from './useHandleEvents'
|
||||
export * from './useHandles'
|
||||
export * from './usePreventNavigation'
|
||||
export * from './useBoundsEvents'
|
||||
export * from './usePosition'
|
||||
|
|
|
@ -3,21 +3,29 @@ import * as React from 'react'
|
|||
import type { TLPageState } from '+types'
|
||||
|
||||
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
|
||||
React.useEffect(() => {
|
||||
ref.current!.style.setProperty('--tl-zoom', pageState.camera.zoom.toString())
|
||||
}, [pageState.camera.zoom])
|
||||
|
||||
// Update the group's position when the camera moves or zooms
|
||||
React.useEffect(() => {
|
||||
const {
|
||||
zoom,
|
||||
point: [x = 0, y = 0],
|
||||
} = pageState.camera
|
||||
rGroup.current?.setAttribute('transform', `scale(${zoom}) translate(${x} ${y})`)
|
||||
}, [pageState.camera])
|
||||
ref.current!.style.setProperty('--tl-camera-x', pageState.camera.point[0] + 'px')
|
||||
ref.current!.style.setProperty('--tl-camera-y', pageState.camera.point[1] + 'px')
|
||||
}, [pageState.camera.point])
|
||||
|
||||
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
|
||||
}
|
||||
|
|
22
packages/core/src/hooks/usePosition.ts
Normal file
22
packages/core/src/hooks/usePosition.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
/* 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(${Math.floor(bounds.width)}px + (var(--tl-padding) * 2))`)
|
||||
elm.style.setProperty(
|
||||
'height',
|
||||
`calc(${Math.floor(bounds.height)}px + (var(--tl-padding) * 2))`
|
||||
)
|
||||
}, [rBounds, bounds, rotation])
|
||||
|
||||
return rBounds
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import * as React from 'react'
|
||||
|
||||
export function usePreventNavigation(rCanvas: React.RefObject<SVGGElement>): void {
|
||||
export function usePreventNavigation(rCanvas: React.RefObject<HTMLDivElement>): void {
|
||||
React.useEffect(() => {
|
||||
const preventGestureNavigation = (event: TouchEvent) => {
|
||||
event.preventDefault()
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import Utils from '+utils'
|
||||
|
||||
export function useRenderOnResize() {
|
||||
const forceUpdate = React.useReducer((x) => x + 1, 0)[1]
|
||||
|
||||
React.useEffect(() => {
|
||||
const debouncedUpdate = Utils.debounce(forceUpdate, 96)
|
||||
window.addEventListener('resize', debouncedUpdate)
|
||||
return () => {
|
||||
window.removeEventListener('resize', debouncedUpdate)
|
||||
}
|
||||
}, [forceUpdate])
|
||||
}
|
|
@ -2,7 +2,7 @@ import { useTLContext } from '+hooks'
|
|||
import * as React from 'react'
|
||||
import { Utils } from '+utils'
|
||||
|
||||
export function useResizeObserver<T extends HTMLElement | SVGElement>(ref: React.RefObject<T>) {
|
||||
export function useResizeObserver<T extends Element>(ref: React.RefObject<T>) {
|
||||
const { inputs } = useTLContext()
|
||||
const rIsMounted = React.useRef(false)
|
||||
const forceUpdate = React.useReducer((x) => x + 1, 0)[1]
|
||||
|
|
|
@ -9,7 +9,7 @@ export function useSafariFocusOutFix(): void {
|
|||
|
||||
useEffect(() => {
|
||||
function handleFocusOut() {
|
||||
callbacks.onBlurEditingShape?.()
|
||||
callbacks.onShapeBlur?.()
|
||||
}
|
||||
|
||||
if (Utils.isMobileSafari()) {
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
||||
export function useSelection<T extends TLShape>(
|
||||
export function useSelection<T extends TLShape, E extends Element>(
|
||||
page: TLPage<T, TLBinding>,
|
||||
pageState: TLPageState,
|
||||
shapeUtils: TLShapeUtils<T>
|
||||
shapeUtils: TLShapeUtils<T, E>
|
||||
) {
|
||||
const { rScreenBounds } = useTLContext()
|
||||
const { selectedIds } = pageState
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import type {
|
||||
|
@ -13,21 +14,21 @@ import type {
|
|||
import { Utils, Vec } from '+utils'
|
||||
|
||||
function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
|
||||
shape: TLShape,
|
||||
branch: IShapeTreeNode<M>[],
|
||||
shape: T,
|
||||
branch: IShapeTreeNode<T, M>[],
|
||||
shapes: TLPage<T, TLBinding>['shapes'],
|
||||
pageState: {
|
||||
bindingTargetId?: string
|
||||
bindingId?: string
|
||||
hoveredId?: string
|
||||
bindingTargetId?: string | null
|
||||
bindingId?: string | null
|
||||
hoveredId?: string | null
|
||||
selectedIds: string[]
|
||||
currentParentId?: string
|
||||
editingId?: string
|
||||
editingBindingId?: string
|
||||
currentParentId?: string | null
|
||||
editingId?: string | null
|
||||
editingBindingId?: string | null
|
||||
},
|
||||
meta?: M
|
||||
) {
|
||||
const node: IShapeTreeNode<M> = {
|
||||
const node: IShapeTreeNode<T, M> = {
|
||||
shape,
|
||||
isCurrentParent: pageState.currentParentId === 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)
|
||||
: false,
|
||||
isBinding: pageState.bindingTargetId === shape.id,
|
||||
meta,
|
||||
meta: meta as any,
|
||||
}
|
||||
|
||||
branch.push(node)
|
||||
|
@ -54,17 +55,21 @@ 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)
|
||||
}
|
||||
|
||||
export function useShapeTree<T extends TLShape, M extends Record<string, unknown>>(
|
||||
export function useShapeTree<
|
||||
T extends TLShape,
|
||||
E extends Element,
|
||||
M extends Record<string, unknown>
|
||||
>(
|
||||
page: TLPage<T, TLBinding>,
|
||||
pageState: TLPageState,
|
||||
shapeUtils: TLShapeUtils<T>,
|
||||
shapeUtils: TLShapeUtils<T, E>,
|
||||
size: number[],
|
||||
meta?: M,
|
||||
onChange?: TLCallbacks['onChange']
|
||||
onRenderCountChange?: TLCallbacks<T>['onRenderCountChange']
|
||||
) {
|
||||
const rTimeout = React.useRef<unknown>()
|
||||
const rPreviousCount = React.useRef(0)
|
||||
|
@ -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)
|
||||
if (
|
||||
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) {
|
||||
shapesIdsToRender.add(shape.id)
|
||||
|
@ -123,7 +128,7 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
|
|||
clearTimeout(rTimeout.current as number)
|
||||
}
|
||||
rTimeout.current = setTimeout(() => {
|
||||
onChange?.(Array.from(shapesIdsToRender.values()))
|
||||
onRenderCountChange?.(Array.from(shapesIdsToRender.values()))
|
||||
}, 100)
|
||||
rPreviousCount.current = shapesToRender.size
|
||||
}
|
||||
|
@ -132,7 +137,7 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
|
|||
|
||||
// Populate the shape tree
|
||||
|
||||
const tree: IShapeTreeNode<M>[] = []
|
||||
const tree: IShapeTreeNode<T, M>[] = []
|
||||
|
||||
const info = { ...pageState, bindingTargetId }
|
||||
|
||||
|
|
|
@ -111,12 +111,20 @@ const tlcss = css`
|
|||
.tl-container {
|
||||
--tl-zoom: 1;
|
||||
--tl-scale: calc(1 / var(--tl-zoom));
|
||||
--tl-camera-x: 0px;
|
||||
--tl-camera-y: 0px;
|
||||
--tl-padding: calc(64px * var(--tl-scale));
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
z-index: 100;
|
||||
touch-action: none;
|
||||
overscroll-behavior: none;
|
||||
background-color: var(--tl-background);
|
||||
|
@ -127,6 +135,58 @@ const tlcss = css`
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tl-canvas {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.tl-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
transform: scale(var(--tl-zoom), var(--tl-zoom))
|
||||
translate(var(--tl-camera-x), var(--tl-camera-y));
|
||||
}
|
||||
|
||||
.tl-absolute {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.tl-positioned {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
overflow: hidden;
|
||||
transform-origin: center center;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tl-positioned-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tl-positioned-div {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: var(--tl-padding);
|
||||
}
|
||||
|
||||
.tl-counter-scaled {
|
||||
transform: scale(var(--tl-scale));
|
||||
}
|
||||
|
@ -190,6 +250,10 @@ const tlcss = css`
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tl-bounds {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tl-bounds-center {
|
||||
fill: transparent;
|
||||
stroke: var(--tl-selectStroke);
|
||||
|
@ -209,17 +273,6 @@ const tlcss = css`
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tl-canvas {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.tl-dot {
|
||||
fill: var(--tl-background);
|
||||
stroke: var(--tl-foreground);
|
||||
|
@ -256,6 +309,7 @@ const tlcss = css`
|
|||
fill: transparent;
|
||||
stroke: none;
|
||||
pointer-events: all;
|
||||
r: calc(20 / max(1, var(--tl-zoom)));
|
||||
}
|
||||
|
||||
.tl-binding-indicator {
|
||||
|
@ -264,18 +318,22 @@ const tlcss = css`
|
|||
stroke: var(--tl-selected);
|
||||
}
|
||||
|
||||
.tl-shape-group {
|
||||
.tl-shape {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tl-shape-group > *[data-shy='true'] {
|
||||
.tl-shape > *[data-shy='true'] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tl-shape-group:hover > *[data-shy='true'] {
|
||||
.tl-shape:hover > *[data-shy='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tl-centered-g {
|
||||
transform: translate(var(--tl-padding), var(--tl-padding));
|
||||
}
|
||||
|
||||
.tl-current-parent > *[data-shy='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
@ -2,16 +2,16 @@ import * as React from 'react'
|
|||
import type { Inputs } from '+inputs'
|
||||
import type { TLCallbacks, TLShape, TLBounds, TLPageState, TLShapeUtils } from '+types'
|
||||
|
||||
export interface TLContextType {
|
||||
export interface TLContextType<T extends TLShape, E extends Element> {
|
||||
id?: string
|
||||
callbacks: Partial<TLCallbacks>
|
||||
shapeUtils: TLShapeUtils<TLShape>
|
||||
callbacks: Partial<TLCallbacks<T>>
|
||||
shapeUtils: TLShapeUtils<T, E>
|
||||
rPageState: React.MutableRefObject<TLPageState>
|
||||
rScreenBounds: React.MutableRefObject<TLBounds | null>
|
||||
inputs: Inputs
|
||||
}
|
||||
|
||||
export const TLContext = React.createContext<TLContextType>({} as TLContextType)
|
||||
export const TLContext = React.createContext({} as TLContextType<TLShape, Element>)
|
||||
|
||||
export function useTLContext() {
|
||||
const context = React.useContext(TLContext)
|
||||
|
|
|
@ -6,7 +6,7 @@ import Utils, { Vec } from '+utils'
|
|||
import { useGesture } from '@use-gesture/react'
|
||||
|
||||
// Capture zoom gestures (pinches, wheels and pans)
|
||||
export function useZoomEvents<T extends HTMLElement | SVGElement>(ref: React.RefObject<T>) {
|
||||
export function useZoomEvents<T extends Element>(ref: React.RefObject<T>) {
|
||||
const rOriginPoint = React.useRef<number[] | undefined>(undefined)
|
||||
const rPinchPoint = React.useRef<number[] | undefined>(undefined)
|
||||
const rDelta = React.useRef<number[]>([0, 0])
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
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'
|
||||
|
||||
export interface BoxShape extends TLShape {
|
||||
size: number[]
|
||||
}
|
||||
|
||||
export class Box extends TLShapeUtil<BoxShape> {
|
||||
export class Box extends TLShapeUtil<BoxShape, SVGGElement> {
|
||||
type = 'box'
|
||||
|
||||
defaultProps = {
|
||||
|
@ -21,13 +21,15 @@ export class Box extends TLShapeUtil<BoxShape> {
|
|||
rotation: 0,
|
||||
}
|
||||
|
||||
create(props: Partial<BoxShape>) {
|
||||
return { ...this.defaultProps, ...props }
|
||||
}
|
||||
|
||||
render(shape: BoxShape, info: TLRenderInfo): JSX.Element {
|
||||
return <rect width={100} height={100} fill="none" stroke="black" />
|
||||
}
|
||||
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} />
|
||||
|
|
|
@ -18,5 +18,6 @@ export const ContextWrapper: React.FC = ({ children }) => {
|
|||
inputs: new Inputs(),
|
||||
}))
|
||||
|
||||
return <TLContext.Provider value={context}>{children}</TLContext.Provider>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return <TLContext.Provider value={context as any}>{children}</TLContext.Provider>
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { TLShapeUtils } from '+types'
|
||||
import { Box, BoxShape } from './box'
|
||||
|
||||
export const mockUtils: TLShapeUtils<BoxShape> = {
|
||||
export const mockUtils: TLShapeUtils<BoxShape, SVGGElement> = {
|
||||
box: new Box(),
|
||||
}
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* --------------------- Primary -------------------- */
|
||||
|
||||
import { Intersect, Vec } from '+utils'
|
||||
import React, { ForwardedRef } from 'react'
|
||||
|
||||
export type Patch<T> = Partial<{ [P in keyof T]: T | Partial<T> | Patch<T[P]> }>
|
||||
|
||||
export interface TLPage<T extends TLShape, B extends TLBinding> {
|
||||
|
@ -21,12 +24,12 @@ export interface TLPageState {
|
|||
zoom: number
|
||||
}
|
||||
brush?: TLBounds
|
||||
pointedId?: string
|
||||
hoveredId?: string
|
||||
editingId?: string
|
||||
bindingId?: string
|
||||
pointedId?: string | null
|
||||
hoveredId?: string | null
|
||||
editingId?: string | null
|
||||
bindingId?: string | null
|
||||
boundsRotation?: number
|
||||
currentParentId?: string
|
||||
currentParentId?: string | null
|
||||
}
|
||||
|
||||
export interface TLHandle {
|
||||
|
@ -54,21 +57,29 @@ export interface TLShape {
|
|||
isAspectRatioLocked?: boolean
|
||||
}
|
||||
|
||||
export type TLShapeUtils<T extends TLShape> = Record<string, TLShapeUtil<T>>
|
||||
export type TLShapeUtils<T extends TLShape, E extends Element> = Record<string, TLShapeUtil<T, E>>
|
||||
|
||||
export interface TLRenderInfo<M = any, T extends SVGElement | HTMLElement = any> {
|
||||
ref?: React.RefObject<T>
|
||||
export interface TLRenderInfo<T extends TLShape, M = any, E = any> {
|
||||
isEditing: boolean
|
||||
isBinding: boolean
|
||||
isHovered: boolean
|
||||
isSelected: boolean
|
||||
isCurrentParent: boolean
|
||||
onTextChange?: TLCallbacks['onTextChange']
|
||||
onTextBlur?: TLCallbacks['onTextBlur']
|
||||
onTextFocus?: TLCallbacks['onTextFocus']
|
||||
onTextKeyDown?: TLCallbacks['onTextKeyDown']
|
||||
onTextKeyUp?: TLCallbacks['onTextKeyUp']
|
||||
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, M, E> {
|
||||
ref: ForwardedRef<E>
|
||||
shape: T
|
||||
}
|
||||
|
||||
export interface TLTool {
|
||||
|
@ -114,9 +125,7 @@ export type TLBoundsHandleEventHandler = (
|
|||
e: React.PointerEvent
|
||||
) => void
|
||||
|
||||
export interface TLCallbacks {
|
||||
onChange: (ids: string[]) => void
|
||||
|
||||
export interface TLCallbacks<T extends TLShape> {
|
||||
// Camera events
|
||||
onPinchStart: TLPinchEventHandler
|
||||
onPinchEnd: TLPinchEventHandler
|
||||
|
@ -172,15 +181,10 @@ export interface TLCallbacks {
|
|||
onUnhoverHandle: TLPointerEventHandler
|
||||
onReleaseHandle: TLPointerEventHandler
|
||||
|
||||
// Text
|
||||
onTextChange: (id: string, text: string) => void
|
||||
onTextBlur: (id: string) => void
|
||||
onTextFocus: (id: string) => void
|
||||
onTextKeyDown: (id: string, key: string) => void
|
||||
onTextKeyUp: (id: string, key: string) => void
|
||||
|
||||
// Misc
|
||||
onBlurEditingShape: () => void
|
||||
onRenderCountChange: (ids: string[]) => void
|
||||
onShapeChange: (shape: { id: string } & Partial<T>) => void
|
||||
onShapeBlur: () => void
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
|
||||
|
@ -261,18 +265,26 @@ export interface TLBezierCurveSegment {
|
|||
/* Shape Utility */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
export abstract class TLShapeUtil<T extends TLShape> {
|
||||
export abstract class TLShapeUtil<T extends TLShape, E extends Element> {
|
||||
refMap = new Map<string, React.RefObject<E>>()
|
||||
|
||||
boundsCache = new WeakMap<TLShape, TLBounds>()
|
||||
|
||||
isEditableText = false
|
||||
|
||||
isAspectRatioLocked = false
|
||||
|
||||
canEdit = false
|
||||
|
||||
canBind = false
|
||||
|
||||
abstract type: T['type']
|
||||
|
||||
abstract defaultProps: T
|
||||
|
||||
abstract render(shape: T, info: TLRenderInfo): JSX.Element | null
|
||||
abstract render: React.ForwardRefExoticComponent<
|
||||
{ shape: T; ref: React.ForwardedRef<E> } & TLRenderInfo<T> & React.RefAttributes<E>
|
||||
>
|
||||
|
||||
abstract renderIndicator(shape: T): JSX.Element | null
|
||||
|
||||
|
@ -280,16 +292,6 @@ export abstract class TLShapeUtil<T extends TLShape> {
|
|||
|
||||
abstract getRotatedBounds(shape: T): TLBounds
|
||||
|
||||
abstract hitTest(shape: T, point: number[]): boolean
|
||||
|
||||
abstract hitTestBounds(shape: T, bounds: TLBounds): boolean
|
||||
|
||||
abstract transform(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T>
|
||||
|
||||
transformSingle(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T> {
|
||||
return this.transform(shape, bounds, info)
|
||||
}
|
||||
|
||||
shouldRender(_prev: T, _next: T): boolean {
|
||||
return true
|
||||
}
|
||||
|
@ -303,6 +305,14 @@ export abstract class TLShapeUtil<T extends TLShape> {
|
|||
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(
|
||||
shape: T,
|
||||
fromShape: TLShape,
|
||||
|
@ -315,7 +325,8 @@ export abstract class TLShapeUtil<T extends TLShape> {
|
|||
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 }
|
||||
}
|
||||
|
||||
|
@ -323,6 +334,14 @@ export abstract class TLShapeUtil<T extends TLShape> {
|
|||
return { ...shape, ...props }
|
||||
}
|
||||
|
||||
transform(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T> | void {
|
||||
return undefined
|
||||
}
|
||||
|
||||
transformSingle(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T> | void {
|
||||
return this.transform(shape, bounds, info)
|
||||
}
|
||||
|
||||
updateChildren<K extends TLShape>(shape: T, children: K[]): Partial<K>[] | void {
|
||||
return
|
||||
}
|
||||
|
@ -376,19 +395,53 @@ export abstract class TLShapeUtil<T extends TLShape> {
|
|||
onStyleChange(shape: T): Partial<T> | void {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
) || Intersect.polyline.bounds(corners, bounds).length > 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- Internal -------------------- */
|
||||
|
||||
export interface IShapeTreeNode<M extends Record<string, unknown>> {
|
||||
shape: TLShape
|
||||
children?: IShapeTreeNode<M>[]
|
||||
export interface IShapeTreeNode<T extends TLShape, M extends Record<string, unknown>> {
|
||||
shape: T
|
||||
children?: IShapeTreeNode<TLShape, M>[]
|
||||
isEditing: boolean
|
||||
isBinding: boolean
|
||||
isHovered: boolean
|
||||
isSelected: boolean
|
||||
isCurrentParent: boolean
|
||||
meta?: M
|
||||
meta?: M extends any ? M : never
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
|
|
|
@ -360,8 +360,8 @@ export class Vec {
|
|||
return Vec.isLeft(p1, pc, p2) > 0
|
||||
}
|
||||
|
||||
static round = (a: number[], d = 5): number[] => {
|
||||
return a.map((v) => +v.toPrecision(d))
|
||||
static round = (a: number[], d = 2): number[] => {
|
||||
return a.map((v) => +v.toFixed(d))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as React from 'react'
|
|||
import { IdProvider } from '@radix-ui/react-id'
|
||||
import { Renderer } from '@tldraw/core'
|
||||
import styled from '~styles'
|
||||
import type { Data, TLDrawDocument } from '~types'
|
||||
import { Data, TLDrawDocument, TLDrawStatus } from '~types'
|
||||
import { TLDrawState } from '~state'
|
||||
import { TLDrawContext, useCustomFonts, useKeyboardShortcuts, useTLDrawContext } from '~hooks'
|
||||
import { tldrawShapeUtils } from '~shape'
|
||||
|
@ -109,7 +109,8 @@ function InnerTldraw({
|
|||
const hideHandles = isInSession || !isSelecting
|
||||
|
||||
// Hide indicators when not using the select tool, or when in session
|
||||
const hideIndicators = isInSession || !isSelecting
|
||||
const hideIndicators =
|
||||
(isInSession && tlstate.appState.status.current !== TLDrawStatus.Brushing) || !isSelecting
|
||||
|
||||
// Custom rendering meta, with dark mode for shapes
|
||||
const meta = React.useMemo(() => ({ isDarkMode }), [isDarkMode])
|
||||
|
@ -197,14 +198,10 @@ function InnerTldraw({
|
|||
onHoverHandle={tlstate.onHoverHandle}
|
||||
onUnhoverHandle={tlstate.onUnhoverHandle}
|
||||
onReleaseHandle={tlstate.onReleaseHandle}
|
||||
onChange={tlstate.onChange}
|
||||
onError={tlstate.onError}
|
||||
onBlurEditingShape={tlstate.onBlurEditingShape}
|
||||
onTextBlur={tlstate.onTextBlur}
|
||||
onTextChange={tlstate.onTextChange}
|
||||
onTextKeyDown={tlstate.onTextKeyDown}
|
||||
onTextFocus={tlstate.onTextFocus}
|
||||
onTextKeyUp={tlstate.onTextKeyUp}
|
||||
onRenderCountChange={tlstate.onRenderCountChange}
|
||||
onShapeChange={tlstate.onShapeChange}
|
||||
onShapeBlur={tlstate.onShapeBlur}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<MenuButtons>
|
||||
|
@ -219,10 +216,14 @@ function InnerTldraw({
|
|||
}
|
||||
|
||||
const Layout = styled('div', {
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
padding: '8px 8px 0 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
|
@ -240,9 +241,6 @@ const Layout = styled('div', {
|
|||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 100,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Rectangle, Ellipse, Arrow, Draw, Text, Group } from './shapes'
|
||||
import { Rectangle, Ellipse, Arrow, Draw, Text, Group, PostIt } from './shapes'
|
||||
import { TLDrawShapeType, TLDrawShape, TLDrawShapeUtil, TLDrawShapeUtils } from '~types'
|
||||
|
||||
export const tldrawShapeUtils: TLDrawShapeUtils = {
|
||||
|
@ -8,18 +8,32 @@ export const tldrawShapeUtils: TLDrawShapeUtils = {
|
|||
[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> {
|
||||
return tldrawShapeUtils[shape.type as T['type']] as TLDrawShapeUtil<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
|
||||
>
|
||||
}
|
||||
|
||||
export function getShapeUtils<T extends TLDrawShape>(shape: T): TLDrawShapeUtil<T> {
|
||||
return tldrawShapeUtils[shape.type as T['type']] as TLDrawShapeUtil<T>
|
||||
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: Partial<TLDrawShape>) {
|
||||
export function createShape<TLDrawShape>(
|
||||
type: TLDrawShapeType,
|
||||
props: { id: string } & Partial<TLDrawShape>
|
||||
) {
|
||||
return tldrawShapeUtils[type].create(props)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
SVGContainer,
|
||||
TLBounds,
|
||||
Utils,
|
||||
Vec,
|
||||
|
@ -7,6 +8,7 @@ import {
|
|||
Intersect,
|
||||
TLHandle,
|
||||
TLPointerInfo,
|
||||
TLShapeProps,
|
||||
} from '@tldraw/core'
|
||||
import getStroke from 'perfect-freehand'
|
||||
import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles'
|
||||
|
@ -19,10 +21,9 @@ import {
|
|||
DashStyle,
|
||||
TLDrawShape,
|
||||
ArrowBinding,
|
||||
TLDrawRenderInfo,
|
||||
} from '~types'
|
||||
|
||||
export class Arrow extends TLDrawShapeUtil<ArrowShape> {
|
||||
export class Arrow extends TLDrawShapeUtil<ArrowShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Arrow as const
|
||||
toolType = TLDrawToolType.Handle
|
||||
canStyleFill = false
|
||||
|
@ -70,62 +71,130 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
|
|||
return next.handles !== prev.handles || next.style !== prev.style
|
||||
}
|
||||
|
||||
render = (shape: ArrowShape, { meta }: TLDrawRenderInfo) => {
|
||||
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 = Utils.getFromCache(this.pathCache, shape, () =>
|
||||
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 ? (
|
||||
// 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 = (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={Math.max(8, strokeWidth * 2)}
|
||||
strokeDasharray="none"
|
||||
strokeDashoffset="none"
|
||||
|
@ -135,7 +204,7 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
|
|||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
fill={isDraw ? styles.stroke : 'none'}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
|
@ -145,110 +214,46 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
|
|||
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
|
||||
)
|
||||
}
|
||||
const sw = strokeWidth * 1.618
|
||||
|
||||
// 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"
|
||||
/>
|
||||
</>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
const path = Utils.getFromCache(this.simplePathCache, shape.handles, () => getArrowPath(shape))
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from 'react'
|
||||
import { TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core'
|
||||
import { SVGContainer, TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core'
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand'
|
||||
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
|
||||
import {
|
||||
|
@ -8,10 +8,10 @@ import {
|
|||
TLDrawShapeUtil,
|
||||
TLDrawShapeType,
|
||||
TLDrawToolType,
|
||||
TLDrawRenderInfo,
|
||||
TLDrawShapeProps,
|
||||
} from '~types'
|
||||
|
||||
export class Draw extends TLDrawShapeUtil<DrawShape> {
|
||||
export class Draw extends TLDrawShapeUtil<DrawShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Draw as const
|
||||
toolType = TLDrawToolType.Draw
|
||||
|
||||
|
@ -37,118 +37,122 @@ export class Draw extends TLDrawShapeUtil<DrawShape> {
|
|||
return next.points !== prev.points || next.style !== prev.style
|
||||
}
|
||||
|
||||
render(shape: DrawShape, { meta, isEditing }: TLDrawRenderInfo): JSX.Element {
|
||||
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
|
||||
if (!isEditing && verySmall) {
|
||||
const sw = strokeWidth * 0.618
|
||||
|
||||
return (
|
||||
<circle
|
||||
r={strokeWidth * 0.618}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
)
|
||||
}
|
||||
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
|
||||
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
|
||||
// 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)
|
||||
)
|
||||
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))
|
||||
const drawPathData = isEditing
|
||||
? getDrawStrokePath(shape, true)
|
||||
: Utils.getFromCache(this.drawPathCache, points, () => getDrawStrokePath(shape, false))
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldFill && (
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{shouldFill && (
|
||||
<path
|
||||
d={polygonPathData}
|
||||
stroke="none"
|
||||
fill={styles.fill}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="fill"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={polygonPathData}
|
||||
stroke="none"
|
||||
fill={styles.fill}
|
||||
d={drawPathData}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="fill"
|
||||
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
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
<path
|
||||
d={drawPathData}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
d={path}
|
||||
fill={shouldFill ? styles.fill : 'none'}
|
||||
stroke="transparent"
|
||||
strokeWidth={Math.min(4, strokeWidth * 2)}
|
||||
strokeLinejoin="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"
|
||||
/>
|
||||
</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
|
||||
|
||||
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 {
|
||||
const { points } = shape
|
||||
|
@ -263,19 +267,6 @@ export class Draw extends TLDrawShapeUtil<DrawShape> {
|
|||
): Partial<DrawShape> {
|
||||
return this.transform(shape, bounds, info)
|
||||
}
|
||||
|
||||
onSessionComplete(shape: DrawShape): Partial<DrawShape> {
|
||||
const bounds = this.getBounds(shape)
|
||||
|
||||
const [x1, y1] = Vec.round(Vec.sub([bounds.minX, bounds.minY], shape.point))
|
||||
|
||||
const points = shape.points.map(([x0, y0, p]) => Vec.round([x0 - x1, y0 - y1]).concat(p))
|
||||
|
||||
return {
|
||||
points,
|
||||
point: Vec.add(shape.point, [x1, y1]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const simulatePressureSettings = {
|
||||
|
@ -317,7 +308,9 @@ function getDrawStrokePath(shape: DrawShape, isEditing: boolean) {
|
|||
|
||||
const stroke = getStroke(shape.points.slice(2), {
|
||||
size: 1 + styles.strokeWidth * 2,
|
||||
thinning: 0.85,
|
||||
thinning: 0.8,
|
||||
streamline: 0.7,
|
||||
smoothing: 0.6,
|
||||
end: { taper: +styles.strokeWidth * 50 },
|
||||
start: { taper: +styles.strokeWidth * 50 },
|
||||
...options,
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import * as React from 'react'
|
||||
import { Utils, TLTransformInfo, TLBounds, Intersect, Vec } from '@tldraw/core'
|
||||
import {
|
||||
SVGContainer,
|
||||
Utils,
|
||||
TLTransformInfo,
|
||||
TLBounds,
|
||||
Intersect,
|
||||
TLShapeProps,
|
||||
Vec,
|
||||
} from '@tldraw/core'
|
||||
import {
|
||||
ArrowShape,
|
||||
DashStyle,
|
||||
EllipseShape,
|
||||
TLDrawRenderInfo,
|
||||
TLDrawShapeType,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawToolType,
|
||||
|
@ -15,7 +22,7 @@ import getStroke from 'perfect-freehand'
|
|||
// TODO
|
||||
// [ ] Improve indicator shape for drawn shapes
|
||||
|
||||
export class Ellipse extends TLDrawShapeUtil<EllipseShape> {
|
||||
export class Ellipse extends TLDrawShapeUtil<EllipseShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Ellipse as const
|
||||
toolType = TLDrawToolType.Bounds
|
||||
pathCache = new WeakMap<EllipseShape, string>([])
|
||||
|
@ -37,32 +44,79 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape> {
|
|||
return next.radius !== prev.radius || next.style !== prev.style
|
||||
}
|
||||
|
||||
render(shape: EllipseShape, { meta, isBinding }: TLDrawRenderInfo) {
|
||||
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))
|
||||
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
|
||||
|
||||
return (
|
||||
<>
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<ellipse
|
||||
className="tl-binding-indicator"
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={rx + 2}
|
||||
ry={ry + 2}
|
||||
rx={rx + 32}
|
||||
ry={ry + 32}
|
||||
/>
|
||||
)}
|
||||
<ellipse
|
||||
|
@ -70,64 +124,19 @@ export class Ellipse extends TLDrawShapeUtil<EllipseShape> {
|
|||
cy={radiusY}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
stroke="none"
|
||||
fill={style.isFilled ? styles.fill : 'none'}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
fill={styles.fill}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
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
|
||||
|
||||
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) {
|
||||
const {
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import * as React from 'react'
|
||||
import { TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core'
|
||||
import { SVGContainer, TLBounds, Utils, Vec, Intersect, TLShapeProps } from '@tldraw/core'
|
||||
import { defaultStyle, getPerfectDashProps } from '~shape/shape-styles'
|
||||
import {
|
||||
GroupShape,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawShapeType,
|
||||
TLDrawToolType,
|
||||
TLDrawRenderInfo,
|
||||
ColorStyle,
|
||||
DashStyle,
|
||||
ArrowShape,
|
||||
|
@ -15,7 +14,7 @@ import {
|
|||
// TODO
|
||||
// [ ] - Find bounds based on common bounds of descendants
|
||||
|
||||
export class Group extends TLDrawShapeUtil<GroupShape> {
|
||||
export class Group extends TLDrawShapeUtil<GroupShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Group as const
|
||||
toolType = TLDrawToolType.Bounds
|
||||
canBind = true
|
||||
|
@ -39,59 +38,68 @@ export class Group extends TLDrawShapeUtil<GroupShape> {
|
|||
return next.size !== prev.size || next.style !== prev.style
|
||||
}
|
||||
|
||||
render(shape: GroupShape, { isBinding, isHovered, isSelected }: TLDrawRenderInfo) {
|
||||
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
|
||||
)
|
||||
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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
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 (
|
||||
<>
|
||||
{isBinding && (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={-32}
|
||||
y={-32}
|
||||
width={size[0] + 64}
|
||||
height={size[1] + 64}
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={-32}
|
||||
y={-32}
|
||||
width={size[0] + 64}
|
||||
height={size[1] + 64}
|
||||
x={0}
|
||||
y={0}
|
||||
width={size[0]}
|
||||
height={size[1]}
|
||||
fill="transparent"
|
||||
pointerEvents="all"
|
||||
/>
|
||||
)}
|
||||
<rect x={0} y={0} width={size[0]} height={size[1]} fill="transparent" pointerEvents="all" />
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
renderIndicator(shape: GroupShape) {
|
||||
const [width, height] = shape.size
|
||||
|
|
|
@ -4,3 +4,4 @@ export * from './rectangle'
|
|||
export * from './ellipse'
|
||||
export * from './text'
|
||||
export * from './group'
|
||||
export * from './post-it'
|
||||
|
|
1
packages/tldraw/src/shape/shapes/post-it/index.ts
Normal file
1
packages/tldraw/src/shape/shapes/post-it/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './post-it'
|
|
@ -0,0 +1,7 @@
|
|||
import { PostIt } from './post-it'
|
||||
|
||||
describe('Post-It shape', () => {
|
||||
it('Creates an instance', () => {
|
||||
new PostIt()
|
||||
})
|
||||
})
|
250
packages/tldraw/src/shape/shapes/post-it/post-it.tsx
Normal file
250
packages/tldraw/src/shape/shapes/post-it/post-it.tsx
Normal file
|
@ -0,0 +1,250 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
TLBounds,
|
||||
Utils,
|
||||
Vec,
|
||||
TLTransformInfo,
|
||||
Intersect,
|
||||
TLShapeProps,
|
||||
HTMLContainer,
|
||||
} from '@tldraw/core'
|
||||
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
|
||||
import { PostItShape, TLDrawShapeUtil, TLDrawShapeType, TLDrawToolType, ArrowShape } from '~types'
|
||||
|
||||
// TODO
|
||||
// [ ] - Make sure that fill does not extend drawn shape at corners
|
||||
|
||||
export class PostIt extends TLDrawShapeUtil<PostItShape, HTMLDivElement> {
|
||||
type = TLDrawShapeType.PostIt as const
|
||||
toolType = TLDrawToolType.Bounds
|
||||
canBind = true
|
||||
pathCache = new WeakMap<number[], string>([])
|
||||
|
||||
defaultProps: PostItShape = {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.PostIt as const,
|
||||
name: 'PostIt',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
text: '',
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
}
|
||||
|
||||
shouldRender(prev: PostItShape, next: PostItShape) {
|
||||
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)
|
||||
|
||||
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>
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
renderIndicator(shape: PostItShape) {
|
||||
const {
|
||||
style,
|
||||
size: [width, height],
|
||||
} = shape
|
||||
|
||||
const styles = getShapeStyle(style, false)
|
||||
const strokeWidth = +styles.strokeWidth
|
||||
|
||||
const sw = strokeWidth
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
rx={1}
|
||||
ry={1}
|
||||
width={Math.max(1, width - sw)}
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
}
|
||||
|
||||
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 = 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]
|
||||
// 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)) ||
|
||||
Intersect.polyline.bounds(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]),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,14 @@
|
|||
import * as React from 'react'
|
||||
import { TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core'
|
||||
import {
|
||||
TLBounds,
|
||||
Utils,
|
||||
Vec,
|
||||
TLTransformInfo,
|
||||
Intersect,
|
||||
TLShapeProps,
|
||||
SVGContainer,
|
||||
HTMLContainer,
|
||||
} from '@tldraw/core'
|
||||
import getStroke from 'perfect-freehand'
|
||||
import { getPerfectDashProps, defaultStyle, getShapeStyle } from '~shape/shape-styles'
|
||||
import {
|
||||
|
@ -8,18 +17,16 @@ import {
|
|||
TLDrawShapeUtil,
|
||||
TLDrawShapeType,
|
||||
TLDrawToolType,
|
||||
TLDrawRenderInfo,
|
||||
ArrowShape,
|
||||
} from '~types'
|
||||
|
||||
// TODO
|
||||
// [ ] - Make sure that fill does not extend drawn shape at corners
|
||||
|
||||
export class Rectangle extends TLDrawShapeUtil<RectangleShape> {
|
||||
export class Rectangle extends TLDrawShapeUtil<RectangleShape, SVGSVGElement> {
|
||||
type = TLDrawShapeType.Rectangle as const
|
||||
toolType = TLDrawToolType.Bounds
|
||||
canBind = true
|
||||
|
||||
pathCache = new WeakMap<number[], string>([])
|
||||
|
||||
defaultProps: RectangleShape = {
|
||||
|
@ -38,105 +45,107 @@ export class Rectangle extends TLDrawShapeUtil<RectangleShape> {
|
|||
return next.size !== prev.size || next.style !== prev.style
|
||||
}
|
||||
|
||||
render(shape: RectangleShape, { isBinding, meta }: TLDrawRenderInfo) {
|
||||
const { id, size, style } = shape
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const strokeWidth = +styles.strokeWidth
|
||||
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
|
||||
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
const pathData = Utils.getFromCache(this.pathCache, shape.size, () => renderPath(shape))
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
const pathData = Utils.getFromCache(this.pathCache, shape.size, () => renderPath(shape))
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
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}
|
||||
x={sw / 2 - 32}
|
||||
y={sw / 2 - 32}
|
||||
width={w + 64}
|
||||
height={h + 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"
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
width={w}
|
||||
height={h}
|
||||
fill={styles.fill}
|
||||
stroke="transparent"
|
||||
strokeWidth={sw}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<path
|
||||
d={pathData}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={styles.strokeWidth}
|
||||
pointerEvents="all"
|
||||
/>
|
||||
</>
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
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) {
|
||||
const {
|
||||
|
@ -264,19 +273,6 @@ export class Rectangle extends TLDrawShapeUtil<RectangleShape> {
|
|||
}
|
||||
}
|
||||
|
||||
hitTest(shape: RectangleShape, point: number[]) {
|
||||
return Utils.pointInBounds(point, this.getBounds(shape))
|
||||
}
|
||||
|
||||
hitTestBounds(shape: RectangleShape, bounds: TLBounds) {
|
||||
const rotatedCorners = Utils.getRotatedCorners(this.getBounds(shape), shape.rotation)
|
||||
|
||||
return (
|
||||
rotatedCorners.every((point) => Utils.pointInBounds(point, bounds)) ||
|
||||
Intersect.polyline.bounds(rotatedCorners, bounds).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
transform(
|
||||
shape: RectangleShape,
|
||||
bounds: TLBounds,
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import { TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core'
|
||||
import { getShapeStyle, getFontSize, getFontStyle, defaultStyle } from '~shape/shape-styles'
|
||||
import { HTMLContainer, TLBounds, Utils, Vec, TLTransformInfo, Intersect } from '@tldraw/core'
|
||||
import { getShapeStyle, getFontStyle, defaultStyle } from '~shape/shape-styles'
|
||||
import {
|
||||
TextShape,
|
||||
TLDrawShapeUtil,
|
||||
TLDrawShapeType,
|
||||
TLDrawRenderInfo,
|
||||
TLDrawToolType,
|
||||
ArrowShape,
|
||||
TLDrawShapeProps,
|
||||
} from '~types'
|
||||
import styled from '~styles'
|
||||
import TextAreaUtils from './text-utils'
|
||||
|
@ -56,7 +57,7 @@ if (typeof window !== 'undefined') {
|
|||
melm = getMeasurementDiv()
|
||||
}
|
||||
|
||||
export class Text extends TLDrawShapeUtil<TextShape> {
|
||||
export class Text extends TLDrawShapeUtil<TextShape, HTMLDivElement> {
|
||||
type = TLDrawShapeType.Text as const
|
||||
toolType = TLDrawToolType.Text
|
||||
isAspectRatioLocked = true
|
||||
|
@ -90,156 +91,116 @@ export class Text extends TLDrawShapeUtil<TextShape> {
|
|||
)
|
||||
}
|
||||
|
||||
render(
|
||||
shape: TextShape,
|
||||
{
|
||||
ref,
|
||||
meta,
|
||||
isEditing,
|
||||
isBinding,
|
||||
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)
|
||||
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)
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
onTextChange?.(id, normalizeText(e.currentTarget.value))
|
||||
}
|
||||
const handleChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onShapeChange?.({ ...shape, text: normalizeText(e.currentTarget.value) })
|
||||
},
|
||||
[shape]
|
||||
)
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
onTextKeyDown?.(id, e.key)
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') return
|
||||
|
||||
if (e.key === 'Escape') return
|
||||
e.stopPropagation()
|
||||
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextAreaUtils.indent(e.currentTarget)
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(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 {
|
||||
TextAreaUtils.indent(e.currentTarget)
|
||||
const elm = rInput.current!
|
||||
elm.setSelectionRange(0, 0)
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
onTextChange?.(id, normalizeText(e.currentTarget.value))
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
onTextBlur?.(id)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function handleFocus(e: React.FocusEvent<HTMLTextAreaElement>) {
|
||||
if (document.activeElement === e.currentTarget) {
|
||||
e.currentTarget.select()
|
||||
onTextFocus?.(id)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown() {
|
||||
if (ref && ref.current.selectionEnd !== 0) {
|
||||
ref.current.selectionEnd = 0
|
||||
}
|
||||
}
|
||||
|
||||
const fontSize = getFontSize(shape.style.size) * (shape.style.scale || 1)
|
||||
|
||||
const lineHeight = fontSize * 1.3
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<>
|
||||
{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}
|
||||
<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}
|
||||
stroke="none"
|
||||
xmlSpace="preserve"
|
||||
dominantBaseline="mathematical"
|
||||
alignmentBaseline="mathematical"
|
||||
>
|
||||
{str}
|
||||
</text>
|
||||
))}
|
||||
</>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
return null
|
||||
|
@ -261,7 +222,8 @@ export class Text extends TLDrawShapeUtil<TextShape> {
|
|||
melm.style.font = getFontStyle(shape.style)
|
||||
|
||||
// In tests, offsetWidth and offsetHeight will be 0
|
||||
const [width, height] = [melm.offsetWidth || 1, melm.offsetHeight || 1]
|
||||
const width = melm.offsetWidth || 1
|
||||
const height = melm.offsetHeight || 1
|
||||
|
||||
return {
|
||||
minX: 0,
|
||||
|
@ -300,7 +262,7 @@ export class Text extends TLDrawShapeUtil<TextShape> {
|
|||
transform(
|
||||
_shape: TextShape,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, scaleX, scaleY, transformOrigin }: TLTransformInfo<TextShape>
|
||||
{ initialShape, scaleX, scaleY }: TLTransformInfo<TextShape>
|
||||
): Partial<TextShape> {
|
||||
const {
|
||||
rotation = 0,
|
||||
|
@ -450,67 +412,30 @@ export class Text extends TLDrawShapeUtil<TextShape> {
|
|||
distance,
|
||||
}
|
||||
}
|
||||
// getBindingPoint(shape, point, origin, direction, expandDistance) {
|
||||
// const bounds = this.getBounds(shape)
|
||||
|
||||
// const expandedBounds = expandBounds(bounds, expandDistance)
|
||||
|
||||
// let bindingPoint: number[]
|
||||
// let distance: number
|
||||
|
||||
// if (!HitTest.bounds(point, expandedBounds)) return
|
||||
|
||||
// // The point is inside of the box, so we'll assume the user is
|
||||
// // indicating a specific point inside of the box.
|
||||
// if (HitTest.bounds(point, bounds)) {
|
||||
// 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.
|
||||
// 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]
|
||||
|
||||
// // The anchor is a point between the handle and the intersection
|
||||
// const anchor = vec.med(point, intersection)
|
||||
|
||||
// // Find the distance between the point and the real bounds of the shape
|
||||
// const distanceFromShape = getBoundsSides(bounds)
|
||||
// .map(side => vec.distanceToLineSegment(side[1][0], side[1][1], point))
|
||||
// .sort((a, b) => a - b)[0]
|
||||
|
||||
// if (vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
|
||||
// // If we're close to the center, snap to the center
|
||||
// bindingPoint = [0.5, 0.5]
|
||||
// } else {
|
||||
// // Or else calculate a normalized point
|
||||
// bindingPoint = vec.divV(vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]), [
|
||||
// expandedBounds.width,
|
||||
// expandedBounds.height,
|
||||
// ])
|
||||
// }
|
||||
|
||||
// distance = distanceFromShape
|
||||
// }
|
||||
|
||||
// return {
|
||||
// point: bindingPoint,
|
||||
// distance,
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
const StyledTextArea = styled('textarea', {
|
||||
zIndex: 1,
|
||||
const StyledWrapper = styled('div', {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
variants: {
|
||||
isEditing: {
|
||||
false: {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
true: {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const StyledTextArea = styled('textarea', {
|
||||
position: 'absolute',
|
||||
top: 'var(--tl-padding)',
|
||||
left: 'var(--tl-padding)',
|
||||
zIndex: 1,
|
||||
width: 'calc(100% - (var(--tl-padding) * 2))',
|
||||
height: 'calc(100% - (var(--tl-padding) * 2))',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
whiteSpace: 'pre',
|
||||
|
@ -523,12 +448,31 @@ const StyledTextArea = styled('textarea', {
|
|||
letterSpacing: LETTER_SPACING,
|
||||
outline: 0,
|
||||
fontWeight: '500',
|
||||
backgroundColor: '$boundsBg',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'all',
|
||||
backfaceVisibility: 'hidden',
|
||||
display: 'inline-block',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
WebkitTouchCallout: 'none',
|
||||
variants: {
|
||||
isBinding: {
|
||||
false: {},
|
||||
true: {
|
||||
background: '$boundsBg',
|
||||
},
|
||||
},
|
||||
isEditing: {
|
||||
false: {
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
background: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
},
|
||||
true: {
|
||||
pointerEvents: 'all',
|
||||
userSelect: 'text',
|
||||
background: '$boundsBg',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -100,7 +100,7 @@ describe('Arrow session', () => {
|
|||
.startHandleSession([200, 200], 'start')
|
||||
.updateHandleSession([91, 9])
|
||||
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.67839, 0.125])
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.68, 0.13])
|
||||
|
||||
tlstate.updateHandleSession([91, 9], false, false, true)
|
||||
})
|
||||
|
@ -112,7 +112,7 @@ describe('Arrow session', () => {
|
|||
.startHandleSession([200, 200], 'start')
|
||||
.updateHandleSession([91, 9])
|
||||
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.67839, 0.125])
|
||||
expect(tlstate.bindings[0].point).toStrictEqual([0.68, 0.13])
|
||||
|
||||
tlstate.updateHandleSession([91, 9], false, false, true)
|
||||
|
||||
|
|
|
@ -8,10 +8,12 @@ import { TLDR } from '~state/tldr'
|
|||
export class DrawSession implements Session {
|
||||
id = 'draw'
|
||||
status = TLDrawStatus.Creating
|
||||
topLeft: number[]
|
||||
origin: number[]
|
||||
previous: number[]
|
||||
last: number[]
|
||||
points: number[][]
|
||||
shiftedPoints: number[][] = []
|
||||
snapshot: DrawSnapshot
|
||||
isLocked?: boolean
|
||||
lockedDirection?: 'horizontal' | 'vertical'
|
||||
|
@ -20,6 +22,7 @@ export class DrawSession implements Session {
|
|||
this.origin = point
|
||||
this.previous = point
|
||||
this.last = point
|
||||
this.topLeft = point
|
||||
|
||||
this.snapshot = getDrawSnapshot(data, id)
|
||||
|
||||
|
@ -48,7 +51,7 @@ export class DrawSession implements Session {
|
|||
const bounds = Utils.getBoundsFromPoints(this.points)
|
||||
if (bounds.width > 8 || bounds.height > 8) {
|
||||
this.isLocked = true
|
||||
const returning = [...this.previous]
|
||||
const returning = [...this.last]
|
||||
|
||||
const isVertical = bounds.height > 8
|
||||
|
||||
|
@ -77,10 +80,9 @@ export class DrawSession implements Session {
|
|||
}
|
||||
|
||||
// The previous input (not adjusted) point
|
||||
this.previous = point
|
||||
|
||||
// The new adjusted point
|
||||
const newPoint = Vec.round(Vec.sub(this.previous, this.origin)).concat(pressure)
|
||||
const newPoint = Vec.round(Vec.sub(point, this.origin)).concat(pressure)
|
||||
|
||||
// Don't add duplicate points. Be sure to
|
||||
// test against the previous *adjusted* point.
|
||||
|
@ -89,16 +91,42 @@ export class DrawSession implements Session {
|
|||
// The new adjusted point is now the previous adjusted point.
|
||||
this.last = newPoint
|
||||
|
||||
// Does the input point create a new top left?
|
||||
const prevTopLeft = [...this.topLeft]
|
||||
|
||||
this.topLeft = [Math.min(this.topLeft[0], point[0]), Math.min(this.topLeft[1], point[1])]
|
||||
|
||||
const delta = Vec.sub(this.topLeft, this.origin)
|
||||
|
||||
// Add the new adjusted point to the points array
|
||||
this.points.push(newPoint)
|
||||
|
||||
// Time to shift some points!
|
||||
|
||||
let points: number[][]
|
||||
|
||||
if (Vec.isEqual(prevTopLeft, this.topLeft)) {
|
||||
// If the new top left is the same as the previous top left,
|
||||
// we don't need to shift anything: we just shift the new point
|
||||
// and add it to the shifted points array.
|
||||
points = [...this.shiftedPoints, Vec.sub(newPoint, delta)]
|
||||
} else {
|
||||
// If we have a new top left, then we need to iterate through
|
||||
// the "unshifted" points array and shift them based on the
|
||||
// offset between the new top left and the original top left.
|
||||
points = this.points.map((pt) => [pt[0] - delta[0], pt[1] - delta[1], pt[2]])
|
||||
}
|
||||
|
||||
this.shiftedPoints = points
|
||||
|
||||
return {
|
||||
document: {
|
||||
pages: {
|
||||
[data.appState.currentPageId]: {
|
||||
shapes: {
|
||||
[snapshot.id]: {
|
||||
points: [...this.points], // Set to a new array here
|
||||
point: this.topLeft,
|
||||
points,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -163,11 +191,7 @@ export class DrawSession implements Session {
|
|||
pages: {
|
||||
[pageId]: {
|
||||
shapes: {
|
||||
[snapshot.id]: TLDR.onSessionComplete(
|
||||
data,
|
||||
{ ...TLDR.getShape(data, snapshot.id, pageId), points: [...this.points] },
|
||||
pageId
|
||||
),
|
||||
[snapshot.id]: TLDR.getShape(data, snapshot.id, pageId),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -30,22 +30,24 @@ export class TextSession implements Session {
|
|||
const { initialShape } = this
|
||||
const pageId = data.appState.currentPageId
|
||||
|
||||
let nextShape: TextShape = {
|
||||
...TLDR.getShape<TextShape>(data, initialShape.id, pageId),
|
||||
text,
|
||||
}
|
||||
// let nextShape: TextShape = {
|
||||
// ...TLDR.getShape<TextShape>(data, initialShape.id, pageId),
|
||||
// text,
|
||||
// }
|
||||
|
||||
nextShape = {
|
||||
...nextShape,
|
||||
...TLDR.getShapeUtils(nextShape).onStyleChange(nextShape),
|
||||
} as TextShape
|
||||
// nextShape = {
|
||||
// ...nextShape,
|
||||
// ...TLDR.getShapeUtils(nextShape).onStyleChange(nextShape),
|
||||
// } as TextShape
|
||||
|
||||
return {
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: {
|
||||
shapes: {
|
||||
[initialShape.id]: nextShape,
|
||||
[initialShape.id]: {
|
||||
text,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -144,7 +146,7 @@ export class TextSession implements Session {
|
|||
},
|
||||
pageState: {
|
||||
[pageId]: {
|
||||
editingId: undefined,
|
||||
editingId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -164,7 +166,7 @@ export class TextSession implements Session {
|
|||
},
|
||||
pageState: {
|
||||
[pageId]: {
|
||||
editingId: undefined,
|
||||
editingId: null,
|
||||
selectedIds: [initialShape.id],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -47,7 +47,7 @@ export class TransformSingleSession implements Session {
|
|||
isAspectRatioLocked || shape.isAspectRatioLocked || utils.isAspectRatioLocked
|
||||
)
|
||||
|
||||
shapes[shape.id] = TLDR.getShapeUtils(shape).transformSingle(shape, newBounds, {
|
||||
const change = TLDR.getShapeUtils(shape).transformSingle(shape, newBounds, {
|
||||
initialShape,
|
||||
type: this.transformType,
|
||||
scaleX: newBounds.scaleX,
|
||||
|
@ -55,6 +55,10 @@ export class TransformSingleSession implements Session {
|
|||
transformOrigin: [0.5, 0.5],
|
||||
})
|
||||
|
||||
if (change) {
|
||||
shapes[shape.id] = change
|
||||
}
|
||||
|
||||
return {
|
||||
document: {
|
||||
pages: {
|
||||
|
|
|
@ -13,7 +13,9 @@ import type {
|
|||
} from '~types'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -664,7 +666,9 @@ export class TLDR {
|
|||
info: TLTransformInfo<T>,
|
||||
pageId: string
|
||||
) {
|
||||
return this.mutate(data, shape, getShapeUtils(shape).transform(shape, bounds, info), pageId)
|
||||
const change = getShapeUtils(shape).transform(shape, bounds, info)
|
||||
if (!change) return shape
|
||||
return this.mutate(data, shape, change, pageId)
|
||||
}
|
||||
|
||||
static transformSingle<T extends TLDrawShape>(
|
||||
|
@ -674,12 +678,9 @@ export class TLDR {
|
|||
info: TLTransformInfo<T>,
|
||||
pageId: string
|
||||
) {
|
||||
return this.mutate(
|
||||
data,
|
||||
shape,
|
||||
getShapeUtils(shape).transformSingle(shape, bounds, info),
|
||||
pageId
|
||||
)
|
||||
const change = getShapeUtils(shape).transformSingle(shape, bounds, info)
|
||||
if (!change) return shape
|
||||
return this.mutate(data, shape, change, pageId)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
|
|
|
@ -14,9 +14,7 @@ import {
|
|||
Vec,
|
||||
brushUpdater,
|
||||
TLPointerInfo,
|
||||
inputs,
|
||||
TLBounds,
|
||||
Patch,
|
||||
} from '@tldraw/core'
|
||||
import {
|
||||
FlipType,
|
||||
|
@ -72,7 +70,7 @@ const defaultState: Data = {
|
|||
settings: {
|
||||
isPenMode: false,
|
||||
isDarkMode: false,
|
||||
isZoomSnap: true,
|
||||
isZoomSnap: false,
|
||||
isDebugMode: process.env.NODE_ENV === 'development',
|
||||
isReadonlyMode: false,
|
||||
nudgeDistanceLarge: 10,
|
||||
|
@ -410,6 +408,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
*/
|
||||
selectTool = (tool: TLDrawShapeType | 'select'): this => {
|
||||
if (this.session) return this
|
||||
|
||||
return this.patchState(
|
||||
{
|
||||
appState: {
|
||||
|
@ -464,10 +463,10 @@ export class TLDrawState extends StateManager<Data> {
|
|||
document: {
|
||||
pageStates: {
|
||||
[this.currentPageId]: {
|
||||
bindingId: undefined,
|
||||
editingId: undefined,
|
||||
hoveredId: undefined,
|
||||
pointedId: undefined,
|
||||
bindingId: null,
|
||||
editingId: null,
|
||||
hoveredId: null,
|
||||
pointedId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1358,6 +1357,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
|
||||
if (result === undefined) {
|
||||
this.isCreating = false
|
||||
|
||||
return this.patchState(
|
||||
{
|
||||
appState: {
|
||||
|
@ -1393,24 +1393,9 @@ export class TLDrawState extends StateManager<Data> {
|
|||
pageStates: {
|
||||
[this.currentPageId]: {
|
||||
selectedIds: [],
|
||||
editingId: undefined,
|
||||
bindingId: undefined,
|
||||
hoveredId: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ...and set editingId back to undefined
|
||||
result.after = {
|
||||
...result.after,
|
||||
document: {
|
||||
...result.after.document,
|
||||
pageStates: {
|
||||
...result.after.document?.pageStates,
|
||||
[this.currentPageId]: {
|
||||
...(result.after.document?.pageStates || {})[this.currentPageId],
|
||||
editingId: undefined,
|
||||
editingId: null,
|
||||
bindingId: null,
|
||||
hoveredId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1433,6 +1418,17 @@ export class TLDrawState extends StateManager<Data> {
|
|||
},
|
||||
}
|
||||
|
||||
result.after.document = {
|
||||
...result.after.document,
|
||||
pageStates: {
|
||||
...result.after.document?.pageStates,
|
||||
[this.currentPageId]: {
|
||||
...(result.after.document?.pageStates || {})[this.currentPageId],
|
||||
editingId: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
this.setState(result, `session:complete:${session.id}`)
|
||||
} else {
|
||||
this.patchState(
|
||||
|
@ -1448,7 +1444,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
document: {
|
||||
pageStates: {
|
||||
[this.currentPageId]: {
|
||||
editingId: undefined,
|
||||
editingId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -2234,11 +2230,11 @@ export class TLDrawState extends StateManager<Data> {
|
|||
}
|
||||
|
||||
onPinchEnd: TLPinchEventHandler = () => {
|
||||
if (this.state.settings.isZoomSnap) {
|
||||
const i = Math.round((this.pageState.camera.zoom * 100) / 25)
|
||||
const nextZoom = TLDR.getCameraZoom(i * 0.25)
|
||||
this.zoomTo(nextZoom, inputs.pointer?.point)
|
||||
}
|
||||
// if (this.state.settings.isZoomSnap) {
|
||||
// const i = Math.round((this.pageState.camera.zoom * 100) / 25)
|
||||
// const nextZoom = TLDR.getCameraZoom(i * 0.25)
|
||||
// this.zoomTo(nextZoom, inputs.pointer?.point)
|
||||
// }
|
||||
this.setStatus(TLDrawStatus.Idle)
|
||||
}
|
||||
|
||||
|
@ -2394,26 +2390,27 @@ export class TLDrawState extends StateManager<Data> {
|
|||
}
|
||||
|
||||
// Start a brush session
|
||||
// TODO: Don't start a brush session right away: we might
|
||||
// be "maybe brushing" or "maybe double clicking"
|
||||
this.startBrushSession(this.getPagePoint(info.point))
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case TLDrawStatus.EditingText: {
|
||||
this.completeSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleClickCanvas: TLCanvasEventHandler = (info) => {
|
||||
onDoubleClickCanvas: TLCanvasEventHandler = () => {
|
||||
// Unused
|
||||
switch (this.appState.status.current) {
|
||||
case TLDrawStatus.Idle: {
|
||||
switch (this.appState.activeTool) {
|
||||
case TLDrawShapeType.Text: {
|
||||
// Create a text shape
|
||||
this.createActiveToolShape(info.point)
|
||||
break
|
||||
}
|
||||
}
|
||||
// TODO: Create a text shape
|
||||
// this.selectTool(TLDrawShapeType.Text)
|
||||
// this.createActiveToolShape(info.point)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -2706,31 +2703,24 @@ export class TLDrawState extends StateManager<Data> {
|
|||
// Unused
|
||||
}
|
||||
|
||||
onTextChange = (id: string, text: string) => {
|
||||
this.updateTextSession(text)
|
||||
onShapeChange = (shape: { id: string } & Partial<TLDrawShape>) => {
|
||||
switch (shape.type) {
|
||||
case TLDrawShapeType.Text: {
|
||||
this.updateTextSession(shape.text || '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onTextBlur = (id: string) => {
|
||||
this.completeSession()
|
||||
onShapeBlur = () => {
|
||||
switch (this.appState.status.current) {
|
||||
case TLDrawStatus.EditingText: {
|
||||
this.completeSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onTextFocus = (id: string) => {
|
||||
// Unused
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onTextKeyDown = (id: string, key: string) => {
|
||||
// Unused
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onTextKeyUp = (id: string, key: string) => {
|
||||
// Unused
|
||||
}
|
||||
|
||||
onChange = (ids: string[]) => {
|
||||
onRenderCountChange = (ids: string[]) => {
|
||||
const appState = this.getAppState()
|
||||
if (appState.isEmptyCanvas && ids.length > 0) {
|
||||
this.patchState(
|
||||
|
@ -2756,8 +2746,4 @@ export class TLDrawState extends StateManager<Data> {
|
|||
onError = () => {
|
||||
// TODO
|
||||
}
|
||||
|
||||
onBlurEditingShape = () => {
|
||||
this.completeSession()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import type { TLBinding, TLRenderInfo } from '@tldraw/core'
|
||||
import type { TLBinding, TLShapeProps } from '@tldraw/core'
|
||||
import { TLShape, TLShapeUtil, TLHandle } from '@tldraw/core'
|
||||
import type { TLPage, TLPageState } from '@tldraw/core'
|
||||
import type { StoreApi } from 'zustand'
|
||||
|
@ -32,7 +32,11 @@ export interface TLDrawMeta {
|
|||
isDarkMode: boolean
|
||||
}
|
||||
|
||||
export type TLDrawRenderInfo = TLRenderInfo<TLDrawMeta>
|
||||
export type TLDrawShapeProps<T extends TLDrawShape, E extends Element> = TLShapeProps<
|
||||
T,
|
||||
E,
|
||||
TLDrawMeta
|
||||
>
|
||||
|
||||
export interface Data {
|
||||
document: TLDrawDocument
|
||||
|
@ -134,6 +138,7 @@ export enum TLDrawToolType {
|
|||
}
|
||||
|
||||
export enum TLDrawShapeType {
|
||||
PostIt = 'post-it',
|
||||
Ellipse = 'ellipse',
|
||||
Rectangle = 'rectangle',
|
||||
Draw = 'draw',
|
||||
|
@ -170,6 +175,7 @@ export interface ArrowShape extends TLDrawBaseShape {
|
|||
middle?: Decoration
|
||||
}
|
||||
}
|
||||
|
||||
export interface EllipseShape extends TLDrawBaseShape {
|
||||
type: TLDrawShapeType.Ellipse
|
||||
radius: number[]
|
||||
|
@ -191,6 +197,12 @@ export interface GroupShape extends TLDrawBaseShape {
|
|||
children: string[]
|
||||
}
|
||||
|
||||
export interface PostItShape extends TLDrawBaseShape {
|
||||
type: TLDrawShapeType.PostIt
|
||||
size: number[]
|
||||
text: string
|
||||
}
|
||||
|
||||
export type TLDrawShape =
|
||||
| RectangleShape
|
||||
| EllipseShape
|
||||
|
@ -198,12 +210,19 @@ export type TLDrawShape =
|
|||
| ArrowShape
|
||||
| TextShape
|
||||
| GroupShape
|
||||
| PostItShape
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export type TLDrawShapeUtils = Record<TLDrawShapeType, TLDrawShapeUtil<TLDrawShape>>
|
||||
export type TLDrawShapeUtils = Record<
|
||||
TLDrawShapeType,
|
||||
TLDrawShapeUtil<TLDrawShape, HTMLElement | SVGElement>
|
||||
>
|
||||
|
||||
export interface ArrowBinding extends TLBinding {
|
||||
type: 'arrow'
|
||||
|
|
Loading…
Reference in a new issue