Merge pull request #84 from tldraw/feature-resize-observer

[feature] resize observer, embedded component improvements
This commit is contained in:
Steve Ruiz 2021-09-09 17:17:17 +01:00 committed by GitHub
commit 1b3084b502
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 150273 additions and 912 deletions

147191
.yarn/releases/yarn-1.19.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

5
.yarnrc Normal file
View file

@ -0,0 +1,5 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
yarn-path ".yarn/releases/yarn-1.19.0.cjs"

View file

@ -44,10 +44,12 @@
"babel-jest": "^27.1.0",
"eslint": "^7.32.0",
"fake-indexeddb": "^3.1.3",
"init-package-json": "^2.0.4",
"jest": "^27.1.0",
"lerna": "^3.15.0",
"lerna": "^3.22.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"resize-observer-polyfill": "^1.5.1",
"ts-jest": "^27.0.5",
"tslib": "^2.3.0",
"typedoc": "^0.21.9",

View file

@ -55,7 +55,7 @@
"react-dom": "^17.0.2"
},
"dependencies": {
"react-use-gesture": "^9.1.3"
"@use-gesture/react": "^10.0.0-beta.24"
},
"gitHead": "55da8880eb3d8ab5fb62b5eb7853065922c95dcf"
}

View file

@ -9,6 +9,7 @@ describe('bounds', () => {
zoom={1}
bounds={{ minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }}
rotation={0}
viewportWidth={1000}
isLocked={false}
/>
)

View file

@ -11,10 +11,18 @@ interface BoundsProps {
bounds: TLBounds
rotation: number
isLocked: boolean
viewportWidth: number
}
export function Bounds({ zoom, bounds, rotation, isLocked }: BoundsProps): JSX.Element {
const size = (Utils.isMobileSize() ? 10 : 8) / zoom // Touch target size
export function Bounds({
zoom,
bounds,
viewportWidth,
rotation,
isLocked,
}: 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 (
@ -28,15 +36,50 @@ export function Bounds({ zoom, bounds, rotation, isLocked }: BoundsProps): JSX.E
<CenterHandle bounds={bounds} isLocked={isLocked} />
{!isLocked && (
<>
<EdgeHandle size={size} bounds={bounds} edge={TLBoundsEdge.Top} />
<EdgeHandle size={size} bounds={bounds} edge={TLBoundsEdge.Right} />
<EdgeHandle size={size} bounds={bounds} edge={TLBoundsEdge.Bottom} />
<EdgeHandle size={size} bounds={bounds} edge={TLBoundsEdge.Left} />
<CornerHandle size={size} bounds={bounds} corner={TLBoundsCorner.TopLeft} />
<CornerHandle size={size} bounds={bounds} corner={TLBoundsCorner.TopRight} />
<CornerHandle size={size} bounds={bounds} corner={TLBoundsCorner.BottomRight} />
<CornerHandle size={size} bounds={bounds} corner={TLBoundsCorner.BottomLeft} />
<RotateHandle size={size} bounds={bounds} />
<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>

View file

@ -9,16 +9,15 @@ const cornerBgClassnames = {
[TLBoundsCorner.BottomLeft]: 'tl-transparent tl-cursor-nesw',
}
interface CornerHandleProps {
size: number
targetSize: number
bounds: TLBounds
corner: TLBoundsCorner
}
export const CornerHandle = React.memo(
({
size,
corner,
bounds,
}: {
size: number
bounds: TLBounds
corner: TLBoundsCorner
}): JSX.Element => {
({ size, targetSize, corner, bounds }: CornerHandleProps): JSX.Element => {
const events = useBoundsHandleEvents(corner)
const isTop = corner === TLBoundsCorner.TopLeft || corner === TLBoundsCorner.TopRight
@ -28,19 +27,20 @@ export const CornerHandle = React.memo(
<g>
<rect
className={cornerBgClassnames[corner]}
x={(isLeft ? -1 : bounds.width + 1) - size}
y={(isTop ? -1 : bounds.height + 1) - size}
width={size * 2}
height={size * 2}
x={(isLeft ? -1 : bounds.width + 1) - targetSize}
y={(isTop ? -1 : bounds.height + 1) - targetSize}
width={targetSize * 2}
height={targetSize * 2}
pointerEvents="all"
fill="red"
{...events}
/>
<rect
className="tl-corner-handle"
x={(isLeft ? -1 : bounds.width + 1) - 4}
y={(isTop ? -1 : bounds.height + 1) - 4}
width={8}
height={8}
x={(isLeft ? -1 : bounds.width + 1) - size / 2}
y={(isTop ? -1 : bounds.height + 1) - size / 2}
width={size}
height={size}
pointerEvents="none"
/>
</g>

View file

@ -9,24 +9,29 @@ const edgeClassnames = {
[TLBoundsEdge.Left]: 'tl-transparent tl-cursor-ew',
}
export const EdgeHandle = React.memo(
({ size, bounds, edge }: { size: number; bounds: TLBounds; edge: TLBoundsEdge }): JSX.Element => {
const events = useBoundsHandleEvents(edge)
interface EdgeHandleProps {
targetSize: number
size: number
bounds: TLBounds
edge: TLBoundsEdge
}
const isHorizontal = edge === TLBoundsEdge.Top || edge === TLBoundsEdge.Bottom
const isFarEdge = edge === TLBoundsEdge.Right || edge === TLBoundsEdge.Bottom
export const EdgeHandle = React.memo(({ size, bounds, edge }: EdgeHandleProps): JSX.Element => {
const events = useBoundsHandleEvents(edge)
const { height, width } = bounds
const isHorizontal = edge === TLBoundsEdge.Top || edge === TLBoundsEdge.Bottom
const isFarEdge = edge === TLBoundsEdge.Right || edge === TLBoundsEdge.Bottom
return (
<rect
className={edgeClassnames[edge]}
x={isHorizontal ? size / 2 : (isFarEdge ? width + 1 : -1) - size / 2}
y={isHorizontal ? (isFarEdge ? height + 1 : -1) - size / 2 : size / 2}
width={isHorizontal ? Math.max(0, width + 1 - size) : size}
height={isHorizontal ? size : Math.max(0, height + 1 - size)}
{...events}
/>
)
}
)
const { height, width } = bounds
return (
<rect
className={edgeClassnames[edge]}
x={isHorizontal ? size / 2 : (isFarEdge ? width + 1 : -1) - size / 2}
y={isHorizontal ? (isFarEdge ? height + 1 : -1) - size / 2 : size / 2}
width={isHorizontal ? Math.max(0, width + 1 - size) : size}
height={isHorizontal ? size : Math.max(0, height + 1 - size)}
{...events}
/>
)
})

View file

@ -2,18 +2,23 @@ import * as React from 'react'
import { useBoundsHandleEvents } from '+hooks'
import type { TLBounds } from '+types'
interface RotateHandleProps {
bounds: TLBounds
size: number
targetSize: number
}
export const RotateHandle = React.memo(
({ bounds, size }: { bounds: TLBounds; size: number }): JSX.Element => {
({ bounds, targetSize, size }: RotateHandleProps): JSX.Element => {
const events = useBoundsHandleEvents('rotate')
return (
<g cursor="grab">
<circle
className="tl-transparent"
cx={bounds.width / 2}
cy={size * -2}
r={size * 2}
fill="transparent"
stroke="none"
r={targetSize * 2}
pointerEvents="all"
{...events}
/>
@ -21,7 +26,7 @@ export const RotateHandle = React.memo(
className="tl-rotate-handle"
cx={bounds.width / 2}
cy={size * -2}
r={4}
r={size / 2}
pointerEvents="none"
/>
</g>

View file

@ -12,6 +12,7 @@ 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'
function resetError() {
void null
@ -35,10 +36,11 @@ export function Canvas<T extends TLShape>({
hideIndicators = false,
}: CanvasProps<T>): JSX.Element {
const rCanvas = React.useRef<SVGSVGElement>(null)
const rContainer = React.useRef<HTMLDivElement>(null)
const rGroup = useCameraCss(pageState)
useResizeObserver(rCanvas)
useZoomEvents()
useZoomEvents(rCanvas)
useSafariFocusOutFix()
@ -46,8 +48,10 @@ export function Canvas<T extends TLShape>({
const events = useCanvasEvents()
const rGroup = useCameraCss(rContainer, pageState)
return (
<div className="tl-container">
<div className="tl-container" ref={rContainer}>
<svg id="canvas" className="tl-canvas" ref={rCanvas} {...events}>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={resetError}>
<Defs zoom={pageState.camera.zoom} />

View file

@ -27,11 +27,9 @@ export function Page<T extends TLShape>({
hideIndicators,
meta,
}: PageProps<T>): JSX.Element {
const { callbacks, shapeUtils } = useTLContext()
const { callbacks, shapeUtils, inputs } = useTLContext()
useRenderOnResize()
const shapeTree = useShapeTree(page, pageState, shapeUtils, meta, callbacks.onChange)
const shapeTree = useShapeTree(page, pageState, shapeUtils, inputs.size, meta, callbacks.onChange)
const { shapeWithHandles } = useHandles(page, pageState)
@ -47,10 +45,16 @@ export function Page<T extends TLShape>({
<>
{bounds && !hideBounds && <BoundsBg bounds={bounds} rotation={rotation} />}
{shapeTree.map((node) => (
<ShapeNode key={node.shape.id} {...node} />
<ShapeNode key={node.shape.id} utils={shapeUtils} {...node} />
))}
{bounds && !hideBounds && (
<Bounds zoom={zoom} bounds={bounds} isLocked={isLocked} rotation={rotation} />
<Bounds
zoom={zoom}
bounds={bounds}
viewportWidth={inputs.size[0]}
isLocked={isLocked}
rotation={rotation}
/>
)}
{!hideIndicators &&
selectedIds

View file

@ -10,6 +10,7 @@ import type {
TLBinding,
} from '../../types'
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>>
@ -87,6 +88,7 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
shapeUtils,
rScreenBounds,
rPageState,
inputs: new Inputs(),
}))
return (

View file

@ -1,10 +1,11 @@
import * as React from 'react'
import type { IShapeTreeNode } from '+types'
import type { IShapeTreeNode, TLShape, TLShapeUtils } from '+types'
import { Shape } from './shape'
export const ShapeNode = React.memo(
<M extends Record<string, unknown>>({
shape,
utils,
children,
isEditing,
isBinding,
@ -12,7 +13,7 @@ export const ShapeNode = React.memo(
isSelected,
isCurrentParent,
meta,
}: IShapeTreeNode<M>) => {
}: { utils: TLShapeUtils<TLShape> } & IShapeTreeNode<M>) => {
return (
<>
<Shape
@ -22,10 +23,13 @@ export const ShapeNode = React.memo(
isHovered={isHovered}
isSelected={isSelected}
isCurrentParent={isCurrentParent}
utils={utils[shape.type]}
meta={meta}
/>
{children &&
children.map((childNode) => <ShapeNode key={childNode.shape.id} {...childNode} />)}
children.map((childNode) => (
<ShapeNode key={childNode.shape.id} utils={utils} {...childNode} />
))}
</>
)
}

View file

@ -7,6 +7,7 @@ describe('shape', () => {
renderWithSvg(
<Shape
shape={mockUtils.box.create({})}
utils={mockUtils[mockUtils.box.type]}
isEditing={false}
isBinding={false}
isHovered={false}

View file

@ -1,22 +1,20 @@
import * as React from 'react'
import { useShapeEvents, useTLContext } from '+hooks'
import type { IShapeTreeNode } from '+types'
import { useShapeEvents } from '+hooks'
import type { IShapeTreeNode, TLShape, TLShapeUtil } from '+types'
import { RenderedShape } from './rendered-shape'
import { EditingTextShape } from './editing-text-shape'
export const Shape = <M extends Record<string, unknown>>({
shape,
utils,
isEditing,
isBinding,
isHovered,
isSelected,
isCurrentParent,
meta,
}: IShapeTreeNode<M>) => {
const { shapeUtils } = useTLContext()
}: { utils: TLShapeUtil<TLShape> } & IShapeTreeNode<M>) => {
const events = useShapeEvents(shape.id, isCurrentParent)
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})`

View file

@ -1,13 +1,13 @@
import * as React from 'react'
import { inputs } from '+inputs'
import { useTLContext } from './useTLContext'
export function useBoundsEvents() {
const { callbacks } = useTLContext()
const { callbacks, inputs } = useTLContext()
const onPointerDown = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return
e.stopPropagation()
e.currentTarget?.setPointerCapture(e.pointerId)
const info = inputs.pointerDown(e, 'bounds')
@ -15,12 +15,13 @@ export function useBoundsEvents() {
callbacks.onPointBounds?.(info, e)
callbacks.onPointerDown?.(info, e)
},
[callbacks]
[callbacks, inputs]
)
const onPointerUp = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return
e.stopPropagation()
const isDoubleClick = inputs.isDoubleClick()
const info = inputs.pointerUp(e, 'bounds')
@ -36,51 +37,42 @@ export function useBoundsEvents() {
callbacks.onReleaseBounds?.(info, e)
callbacks.onPointerUp?.(info, e)
},
[callbacks]
[callbacks, inputs]
)
const onPointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (inputs.pointer && e.pointerId !== inputs.pointer.pointerId) return
if (!inputs.pointerIsValid(e)) return
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
callbacks.onDragBounds?.(inputs.pointerMove(e, 'bounds'), e)
}
const info = inputs.pointerMove(e, 'bounds')
callbacks.onPointerMove?.(info, e)
},
[callbacks]
[callbacks, inputs]
)
const onPointerEnter = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
callbacks.onHoverBounds?.(inputs.pointerEnter(e, 'bounds'), e)
},
[callbacks]
[callbacks, inputs]
)
const onPointerLeave = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
callbacks.onUnhoverBounds?.(inputs.pointerEnter(e, 'bounds'), e)
},
[callbacks]
[callbacks, inputs]
)
const onTouchStart = React.useCallback((e: React.TouchEvent) => {
e.preventDefault()
}, [])
const onTouchEnd = React.useCallback((e: React.TouchEvent) => {
e.preventDefault()
}, [])
return {
onPointerDown,
onPointerUp,
onPointerEnter,
onPointerMove,
onPointerLeave,
onTouchStart,
onTouchEnd,
}
}

View file

@ -1,14 +1,14 @@
import * as React from 'react'
import { inputs } from '+inputs'
import type { TLBoundsEdge, TLBoundsCorner } from '+types'
import { useTLContext } from './useTLContext'
export function useBoundsHandleEvents(id: TLBoundsCorner | TLBoundsEdge | 'rotate') {
const { callbacks } = useTLContext()
const { callbacks, inputs } = useTLContext()
const onPointerDown = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return
e.stopPropagation()
e.currentTarget?.setPointerCapture(e.pointerId)
const info = inputs.pointerDown(e, id)
@ -16,12 +16,13 @@ export function useBoundsHandleEvents(id: TLBoundsCorner | TLBoundsEdge | 'rotat
callbacks.onPointBoundsHandle?.(info, e)
callbacks.onPointerDown?.(info, e)
},
[callbacks, id]
[inputs, callbacks, id]
)
const onPointerUp = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return
e.stopPropagation()
const isDoubleClick = inputs.isDoubleClick()
const info = inputs.pointerUp(e, id)
@ -37,49 +38,42 @@ export function useBoundsHandleEvents(id: TLBoundsCorner | TLBoundsEdge | 'rotat
callbacks.onReleaseBoundsHandle?.(info, e)
callbacks.onPointerUp?.(info, e)
},
[callbacks, id]
[inputs, callbacks, id]
)
const onPointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
callbacks.onDragBoundsHandle?.(inputs.pointerMove(e, id), e)
}
const info = inputs.pointerMove(e, id)
callbacks.onPointerMove?.(info, e)
},
[callbacks, id]
[inputs, callbacks, id]
)
const onPointerEnter = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
callbacks.onHoverBoundsHandle?.(inputs.pointerEnter(e, id), e)
},
[callbacks, id]
[inputs, callbacks, id]
)
const onPointerLeave = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
callbacks.onUnhoverBoundsHandle?.(inputs.pointerEnter(e, id), e)
},
[callbacks, id]
[inputs, callbacks, id]
)
const onTouchStart = React.useCallback((e: React.TouchEvent) => {
e.preventDefault()
}, [])
const onTouchEnd = React.useCallback((e: React.TouchEvent) => {
e.preventDefault()
}, [])
return {
onPointerDown,
onPointerUp,
onPointerEnter,
onPointerMove,
onPointerLeave,
onTouchStart,
onTouchEnd,
}
}

View file

@ -1,12 +1,13 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TLPageState } from '+types'
export function useCameraCss(pageState: TLPageState) {
export function useCameraCss(ref: React.RefObject<HTMLDivElement>, pageState: TLPageState) {
const rGroup = React.useRef<SVGGElement>(null)
// Update the tl-zoom CSS variable when the zoom changes
React.useEffect(() => {
document.documentElement.style.setProperty('--tl-zoom', pageState.camera.zoom.toString())
ref.current!.style.setProperty('--tl-zoom', pageState.camera.zoom.toString())
}, [pageState.camera.zoom])
// Update the group's position when the camera moves or zooms

View file

@ -1,13 +1,13 @@
import * as React from 'react'
import { useTLContext } from './useTLContext'
import { inputs } from '+inputs'
export function useCanvasEvents() {
const { callbacks } = useTLContext()
const { callbacks, inputs } = useTLContext()
const onPointerDown = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return
e.currentTarget.setPointerCapture(e.pointerId)
if (e.button === 0) {
@ -16,11 +16,12 @@ export function useCanvasEvents() {
callbacks.onPointerDown?.(info, e)
}
},
[callbacks]
[callbacks, inputs]
)
const onPointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
const info = inputs.pointerMove(e, 'canvas')
callbacks.onDragCanvas?.(info, e)
@ -28,12 +29,13 @@ export function useCanvasEvents() {
const info = inputs.pointerMove(e, 'canvas')
callbacks.onPointerMove?.(info, e)
},
[callbacks]
[callbacks, inputs]
)
const onPointerUp = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return
const isDoubleClick = inputs.isDoubleClick()
const info = inputs.pointerUp(e, 'canvas')
@ -47,7 +49,7 @@ export function useCanvasEvents() {
callbacks.onReleaseCanvas?.(info, e)
callbacks.onPointerUp?.(info, e)
},
[callbacks]
[callbacks, inputs]
)
return {

View file

@ -1,13 +1,13 @@
import * as React from 'react'
import { inputs } from '+inputs'
import { useTLContext } from './useTLContext'
export function useHandleEvents(id: string) {
const { callbacks } = useTLContext()
const { inputs, callbacks } = useTLContext()
const onPointerDown = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return
e.stopPropagation()
e.currentTarget?.setPointerCapture(e.pointerId)
@ -15,12 +15,13 @@ export function useHandleEvents(id: string) {
callbacks.onPointHandle?.(info, e)
callbacks.onPointerDown?.(info, e)
},
[callbacks, id]
[inputs, callbacks, id]
)
const onPointerUp = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return
e.stopPropagation()
const isDoubleClick = inputs.isDoubleClick()
const info = inputs.pointerUp(e, id)
@ -36,11 +37,12 @@ export function useHandleEvents(id: string) {
}
callbacks.onPointerUp?.(info, e)
},
[callbacks]
[inputs, callbacks]
)
const onPointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
const info = inputs.pointerMove(e, id)
callbacks.onDragHandle?.(info, e)
@ -48,40 +50,32 @@ export function useHandleEvents(id: string) {
const info = inputs.pointerMove(e, id)
callbacks.onPointerMove?.(info, e)
},
[callbacks, id]
[inputs, callbacks, id]
)
const onPointerEnter = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
const info = inputs.pointerEnter(e, id)
callbacks.onHoverHandle?.(info, e)
},
[callbacks, id]
[inputs, callbacks, id]
)
const onPointerLeave = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
const info = inputs.pointerEnter(e, id)
callbacks.onUnhoverHandle?.(info, e)
},
[callbacks, id]
[inputs, callbacks, id]
)
const onTouchStart = React.useCallback((e: React.TouchEvent) => {
e.preventDefault()
}, [])
const onTouchEnd = React.useCallback((e: React.TouchEvent) => {
e.preventDefault()
}, [])
return {
onPointerDown,
onPointerUp,
onPointerEnter,
onPointerMove,
onPointerLeave,
onTouchStart,
onTouchEnd,
}
}

View file

@ -0,0 +1,55 @@
import { useTLContext } from '+hooks'
import * as React from 'react'
import { Utils } from '+utils'
export function useResizeObserver<T extends HTMLElement | SVGElement>(ref: React.RefObject<T>) {
const { inputs } = useTLContext()
const rIsMounted = React.useRef(false)
const forceUpdate = React.useReducer((x) => x + 1, 0)[1]
const updateOffsets = React.useCallback(() => {
if (rIsMounted.current) {
const rect = ref.current?.getBoundingClientRect()
if (rect) {
inputs.offset = [rect.left, rect.top]
inputs.size = [rect.width, rect.height]
forceUpdate()
}
}
rIsMounted.current = true
}, [ref, forceUpdate])
React.useEffect(() => {
const debouncedUpdateOffsets = Utils.debounce(updateOffsets, 100)
window.addEventListener('scroll', debouncedUpdateOffsets)
window.addEventListener('resize', debouncedUpdateOffsets)
return () => {
window.removeEventListener('scroll', debouncedUpdateOffsets)
window.removeEventListener('resize', debouncedUpdateOffsets)
}
}, [inputs])
React.useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
if (inputs.isPinching) {
return
}
if (entries[0].contentRect) {
updateOffsets()
}
})
if (ref.current) {
resizeObserver.observe(ref.current)
}
return () => {
resizeObserver.disconnect()
}
}, [ref, inputs])
React.useEffect(() => {
updateOffsets()
}, [ref])
}

View file

@ -1,14 +1,14 @@
import * as React from 'react'
import { inputs } from '+inputs'
import { Utils } from '+utils'
import { TLContext } from '+hooks'
export function useShapeEvents(id: string, disable = false) {
const { rPageState, rScreenBounds, callbacks } = React.useContext(TLContext)
const { rPageState, rScreenBounds, callbacks, inputs } = React.useContext(TLContext)
const onPointerDown = React.useCallback(
(e: React.PointerEvent) => {
if (disable) return
if (!inputs.pointerIsValid(e)) return
if (e.button === 2) {
callbacks.onRightPointShape?.(inputs.pointerDown(e, id), e)
@ -38,12 +38,13 @@ export function useShapeEvents(id: string, disable = false) {
callbacks.onPointShape?.(info, e)
callbacks.onPointerDown?.(info, e)
},
[callbacks, id, disable]
[inputs, callbacks, id, disable]
)
const onPointerUp = React.useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
if (!inputs.pointerIsValid(e)) return
if (disable) return
e.stopPropagation()
const isDoubleClick = inputs.isDoubleClick()
@ -60,11 +61,12 @@ export function useShapeEvents(id: string, disable = false) {
callbacks.onReleaseShape?.(info, e)
callbacks.onPointerUp?.(info, e)
},
[callbacks, id, disable]
[inputs, callbacks, id, disable]
)
const onPointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
if (disable) return
if (inputs.pointer && e.pointerId !== inputs.pointer.pointerId) return
@ -77,42 +79,34 @@ export function useShapeEvents(id: string, disable = false) {
callbacks.onPointerMove?.(info, e)
},
[callbacks, id, disable]
[inputs, callbacks, id, disable]
)
const onPointerEnter = React.useCallback(
(e: React.PointerEvent) => {
if (!inputs.pointerIsValid(e)) return
if (disable) return
const info = inputs.pointerEnter(e, id)
callbacks.onHoverShape?.(info, e)
},
[callbacks, id, disable]
[inputs, callbacks, id, disable]
)
const onPointerLeave = React.useCallback(
(e: React.PointerEvent) => {
if (disable) return
if (!inputs.pointerIsValid(e)) return
const info = inputs.pointerEnter(e, id)
callbacks.onUnhoverShape?.(info, e)
},
[callbacks, id, disable]
[inputs, callbacks, id, disable]
)
const onTouchStart = React.useCallback((e: React.TouchEvent) => {
e.preventDefault()
}, [])
const onTouchEnd = React.useCallback((e: React.TouchEvent) => {
e.preventDefault()
}, [])
return {
onPointerDown,
onPointerUp,
onPointerEnter,
onPointerMove,
onPointerLeave,
onTouchStart,
onTouchEnd,
}
}

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type {
IShapeTreeNode,
@ -7,6 +8,7 @@ import type {
TLShapeUtils,
TLCallbacks,
TLBinding,
TLBounds,
} from '+types'
import { Utils, Vec } from '+utils'
@ -52,28 +54,32 @@ function addToShapeTree<T extends TLShape, M extends Record<string, unknown>>(
}
}
function shapeIsInViewport(shape: TLShape, bounds: TLBounds, viewport: TLBounds) {
return Utils.boundsContain(viewport, bounds) || Utils.boundsCollide(viewport, bounds)
}
export function useShapeTree<T extends TLShape, M extends Record<string, unknown>>(
page: TLPage<T, TLBinding>,
pageState: TLPageState,
shapeUtils: TLShapeUtils<T>,
size: number[],
meta?: M,
onChange?: TLCallbacks['onChange']
) {
const rTimeout = React.useRef<unknown>()
const rPreviousCount = React.useRef(0)
if (typeof window === 'undefined') return []
const rShapesIdsToRender = React.useRef(new Set<string>())
const rShapesToRender = React.useRef(new Set<TLShape>())
const { selectedIds, camera } = pageState
// Find viewport
// Filter the page's shapes down to only those that:
// - are the direct child of the page
// - collide with or are contained by the viewport
// - OR are selected
const [minX, minY] = Vec.sub(Vec.div([0, 0], camera.zoom), camera.point)
const [maxX, maxY] = Vec.sub(
Vec.div([window.innerWidth, window.innerHeight], camera.zoom),
camera.point
)
const [maxX, maxY] = Vec.sub(Vec.div(size, camera.zoom), camera.point)
const viewport = {
minX,
minY,
@ -83,28 +89,43 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
width: maxY - minY,
}
// Filter shapes that are in view, and that are the direct child of
// the page. Other shapes are not visible, or will be rendered as
// the children of groups.
const shapesToRender = rShapesToRender.current
const shapesIdsToRender = rShapesIdsToRender.current
const shapesToRender = Object.values(page.shapes).filter((shape) => {
if (shape.parentId !== page.id) return false
shapesToRender.clear()
shapesIdsToRender.clear()
// Don't hide selected shapes (this breaks certain drag interactions)
if (selectedIds.includes(shape.id)) return true
const shapeBounds = shapeUtils[shape.type as T['type']].getBounds(shape)
return Utils.boundsContain(viewport, shapeBounds) || Utils.boundsCollide(viewport, shapeBounds)
})
Object.values(page.shapes)
.filter((shape) => {
// 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)
) {
if (shape.parentId === page.id) {
shapesIdsToRender.add(shape.id)
shapesToRender.add(shape)
} else {
shapesIdsToRender.add(shape.parentId)
shapesToRender.add(page.shapes[shape.parentId])
}
}
})
.sort((a, b) => a.childIndex - b.childIndex)
// Call onChange callback when number of rendering shapes changes
if (shapesToRender.length !== rPreviousCount.current) {
// Use a timeout to clear call stack, in case the onChange handleer
// produces a new state change (React won't like that)
setTimeout(() => onChange?.(shapesToRender.map((shape) => shape.id)), 0)
rPreviousCount.current = shapesToRender.length
if (shapesToRender.size !== rPreviousCount.current) {
// Use a timeout to clear call stack, in case the onChange handler
// produces a new state change, which could cause nested state
// changes, which is bad in React.
if (rTimeout.current) {
clearTimeout(rTimeout.current as number)
}
rTimeout.current = setTimeout(() => {
onChange?.(Array.from(shapesIdsToRender.values()))
}, 100)
rPreviousCount.current = shapesToRender.size
}
const bindingTargetId = pageState.bindingId ? page.bindings[pageState.bindingId].toId : undefined
@ -113,11 +134,9 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
const tree: IShapeTreeNode<M>[] = []
shapesToRender
.sort((a, b) => a.childIndex - b.childIndex)
.forEach((shape) =>
addToShapeTree(shape, tree, page.shapes, { ...pageState, bindingTargetId }, meta)
)
const info = { ...pageState, bindingTargetId }
shapesToRender.forEach((shape) => addToShapeTree(shape, tree, page.shapes, info, meta))
return tree
}

View file

@ -108,151 +108,174 @@ const tlcss = css`
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
html,
* {
box-sizing: border-box;
}
:root {
.tl-container {
--tl-zoom: 1;
--tl-scale: calc(1 / var(--tl-zoom));
}
.tl-counter-scaled {
transform: scale(var(--tl-scale));
}
.tl-dashed {
stroke-dasharray: calc(2px * var(--tl-scale)), calc(2px * var(--tl-scale));
}
.tl-transparent {
fill: transparent;
stroke: transparent;
}
.tl-cursor-ns {
cursor: ns-resize;
}
.tl-cursor-ew {
cursor: ew-resize;
}
.tl-cursor-nesw {
cursor: nesw-resize;
}
.tl-cursor-nwse {
cursor: nwse-resize;
}
.tl-corner-handle {
stroke: var(--tl-selectStroke);
fill: var(--tl-background);
stroke-width: calc(1.5px * var(--tl-scale));
}
.tl-rotate-handle {
stroke: var(--tl-selectStroke);
fill: var(--tl-background);
stroke-width: calc(1.5px * var(--tl-scale));
cursor: grab;
}
.tl-binding {
fill: var(--tl-selectFill);
stroke: var(--tl-selectStroke);
stroke-width: calc(1px * var(--tl-scale));
pointer-events: none;
}
.tl-selected {
fill: transparent;
stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale));
pointer-events: none;
}
.tl-hovered {
fill: transparent;
stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale));
pointer-events: none;
}
.tl-bounds-center {
fill: transparent;
stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale));
}
.tl-bounds-bg {
stroke: none;
fill: var(--tl-selectFill);
pointer-events: all;
}
.tl-brush {
fill: var(--tl-brushFill);
stroke: var(--tl-brushStroke);
stroke-width: calc(1px * var(--tl-scale));
pointer-events: none;
}
.tl-canvas {
position: fixed;
overflow: hidden;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
touch-action: none;
z-index: 100;
pointer-events: all;
}
.tl-container {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0px;
margin: 0px;
touch-action: none;
overscroll-behavior: none;
overscroll-behavior-x: none;
background-color: var(--tl-background);
}
.tl-container * {
user-select: none;
box-sizing: border-box;
}
.tl-counter-scaled {
transform: scale(var(--tl-scale));
}
.tl-dashed {
stroke-dasharray: calc(2px * var(--tl-scale)), calc(2px * var(--tl-scale));
}
.tl-transparent {
fill: transparent;
stroke: transparent;
}
.tl-cursor-ns {
cursor: ns-resize;
}
.tl-cursor-ew {
cursor: ew-resize;
}
.tl-cursor-nesw {
cursor: nesw-resize;
}
.tl-cursor-nwse {
cursor: nwse-resize;
}
.tl-corner-handle {
stroke: var(--tl-selectStroke);
fill: var(--tl-background);
stroke-width: calc(1.5px * var(--tl-scale));
}
.tl-rotate-handle {
stroke: var(--tl-selectStroke);
fill: var(--tl-background);
stroke-width: calc(1.5px * var(--tl-scale));
cursor: grab;
}
.tl-binding {
fill: var(--tl-selectFill);
stroke: var(--tl-selectStroke);
stroke-width: calc(1px * var(--tl-scale));
pointer-events: none;
}
.tl-selected {
fill: transparent;
stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale));
pointer-events: none;
}
.tl-hovered {
fill: transparent;
stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale));
pointer-events: none;
}
.tl-bounds-center {
fill: transparent;
stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale));
}
.tl-bounds-bg {
stroke: none;
fill: var(--tl-selectFill);
pointer-events: all;
}
.tl-brush {
fill: var(--tl-brushFill);
stroke: var(--tl-brushStroke);
stroke-width: calc(1px * var(--tl-scale));
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);
stroke-width: 2px;
}
.tl-handles {
pointer-events: all;
}
.tl-handles:hover > .tl-handle-bg {
fill: var(--tl-selectFill);
}
.tl-handles:hover > .tl-handle-bg > * {
stroke: var(--tl-selectFill);
}
.tl-handles:active > .tl-handle-bg {
fill: var(--tl-selectFill);
}
.tl-handles:active > .tl-handle-bg > * {
stroke: var(--tl-selectFill);
}
.tl-handle {
fill: var(--tl-background);
stroke: var(--tl-selectStroke);
stroke-width: 1.5px;
}
.tl-handle-bg {
fill: transparent;
stroke: none;
pointer-events: all;
}
.tl-binding-indicator {
stroke-width: calc(3px * var(--tl-scale));
fill: var(--tl-selectFill);
stroke: var(--tl-selected);
}
.tl-shape-group {
outline: none;
}
.tl-shape-group > *[data-shy='true'] {
opacity: 0;
}
.tl-shape-group:hover > *[data-shy='true'] {
opacity: 1;
}
.tl-current-parent > *[data-shy='true'] {
opacity: 1;
}

View file

@ -1,4 +1,5 @@
import * as React from 'react'
import type { Inputs } from '+inputs'
import type { TLCallbacks, TLShape, TLBounds, TLPageState, TLShapeUtils } from '+types'
export interface TLContextType {
@ -7,6 +8,7 @@ export interface TLContextType {
shapeUtils: TLShapeUtils<TLShape>
rPageState: React.MutableRefObject<TLPageState>
rScreenBounds: React.MutableRefObject<TLBounds | null>
inputs: Inputs
}
export const TLContext = React.createContext<TLContextType>({} as TLContextType)

View file

@ -1,81 +1,99 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { useRef } from 'react'
import * as React from 'react'
import { useTLContext } from './useTLContext'
import { Vec } from '+utils'
import { useWheel, usePinch } from 'react-use-gesture'
import { inputs } from '+inputs'
import Utils, { Vec } from '+utils'
import { useGesture } from '@use-gesture/react'
// Capture zoom gestures (pinches, wheels and pans)
export function useZoomEvents() {
const rPinchDa = useRef<number[] | undefined>(undefined)
const rOriginPoint = useRef<number[] | undefined>(undefined)
const rPinchPoint = useRef<number[] | undefined>(undefined)
export function useZoomEvents<T extends HTMLElement | SVGElement>(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])
const { callbacks } = useTLContext()
const { inputs, callbacks } = useTLContext()
useWheel(
({ event: e, delta }) => {
e.preventDefault()
if (Vec.isEqual(delta, [0, 0])) return
const info = inputs.pan(delta, e as WheelEvent)
callbacks.onPan?.(info, e)
},
{
domTarget: typeof document === 'undefined' ? undefined : document.body,
eventOptions: { passive: false },
React.useEffect(() => {
const preventGesture = (event: TouchEvent) => {
event.preventDefault()
}
)
usePinch(
({ pinching, da, origin, event: e }) => {
if (!pinching) {
// @ts-ignore
document.addEventListener('gesturestart', preventGesture)
// @ts-ignore
document.addEventListener('gesturechange', preventGesture)
return () => {
// @ts-ignore
document.removeEventListener('gesturestart', preventGesture)
// @ts-ignore
document.removeEventListener('gesturechange', preventGesture)
}
}, [])
useGesture(
{
onWheel: ({ event: e, delta }) => {
const elm = ref.current
if (!(e.target === elm || elm?.contains(e.target as Node))) return
e.preventDefault()
if (inputs.isPinching) return
if (Vec.isEqual(delta, [0, 0])) return
const info = inputs.pan(delta, e as WheelEvent)
callbacks.onPan?.(info, e)
},
onPinchStart: ({ origin, event }) => {
const elm = ref.current
if (!(event.target === elm || elm?.contains(event.target as Node))) return
const info = inputs.pinch(origin, origin)
callbacks.onPinchEnd?.(
info,
e as React.WheelEvent<Element> | WheelEvent | React.TouchEvent<Element> | TouchEvent
)
rPinchDa.current = undefined
inputs.isPinching = true
callbacks.onPinchStart?.(info, event)
rPinchPoint.current = info.point
rOriginPoint.current = info.origin
rDelta.current = [0, 0]
},
onPinchEnd: ({ origin, event }) => {
const elm = ref.current
if (!(event.target === elm || elm?.contains(event.target as Node))) return
const info = inputs.pinch(origin, origin)
inputs.isPinching = false
callbacks.onPinchEnd?.(info, event)
rPinchPoint.current = undefined
rOriginPoint.current = undefined
return
}
rDelta.current = [0, 0]
},
onPinch: ({ delta, origin, event }) => {
const elm = ref.current
if (!(event.target === elm || elm?.contains(event.target as Node))) return
if (!rOriginPoint.current) throw Error('No origin point!')
if (rPinchPoint.current === undefined) {
const info = inputs.pinch(origin, origin)
callbacks.onPinchStart?.(
info,
e as React.WheelEvent<Element> | WheelEvent | React.TouchEvent<Element> | TouchEvent
const info = inputs.pinch(origin, rOriginPoint.current)
const trueDelta = Vec.sub(info.delta, rDelta.current)
rDelta.current = info.delta
callbacks.onPinch?.(
{
...info,
point: info.point,
origin: rOriginPoint.current,
delta: [...trueDelta, -delta[0]],
},
event
)
rPinchDa.current = da
rPinchPoint.current = origin
rOriginPoint.current = origin
}
if (!rPinchDa.current) throw Error('No pinch direction!')
if (!rOriginPoint.current) throw Error('No origin point!')
const [distanceDelta] = Vec.sub(rPinchDa.current, da)
const info = inputs.pinch(rPinchPoint.current, origin)
callbacks.onPinch?.(
{
...info,
point: origin,
origin: rOriginPoint.current,
delta: [...info.delta, distanceDelta],
},
e as React.WheelEvent<Element> | WheelEvent | React.TouchEvent<Element> | TouchEvent
)
rPinchDa.current = da
rPinchPoint.current = origin
},
},
{
domTarget: typeof document === 'undefined' ? undefined : document.body,
target: window,
eventOptions: { passive: false },
}
)

View file

@ -4,19 +4,39 @@ import { Vec, Utils } from './utils'
const DOUBLE_CLICK_DURATION = 250
class Inputs {
export class Inputs {
pointer?: TLPointerInfo<string>
keyboard?: TLKeyboardInfo
keys: Record<string, boolean> = {}
isPinching = false
offset = [0, 0]
size = [10, 10]
pointerUpTime = 0
activePointer?: number
pointerIsValid(e: TouchEvent | React.TouchEvent | PointerEvent | React.PointerEvent) {
if ('pointerId' in e) {
if (this.activePointer && this.activePointer !== e.pointerId) return false
}
if ('touches' in e) {
const touch = e.changedTouches[0]
if (this.activePointer && this.activePointer !== touch.identifier) return false
}
return true
}
touchStart<T extends string>(e: TouchEvent | React.TouchEvent, target: T): TLPointerInfo<T> {
const { shiftKey, ctrlKey, metaKey, altKey } = e
e.preventDefault()
const touch = e.changedTouches[0]
this.activePointer = touch.identifier
const info: TLPointerInfo<T> = {
target,
pointerId: touch.identifier,
@ -35,9 +55,33 @@ class Inputs {
return info
}
touchEnd<T extends string>(e: TouchEvent | React.TouchEvent, target: T): TLPointerInfo<T> {
const { shiftKey, ctrlKey, metaKey, altKey } = e
const touch = e.changedTouches[0]
const info: TLPointerInfo<T> = {
target,
pointerId: touch.identifier,
origin: Inputs.getPoint(touch),
delta: [0, 0],
point: Inputs.getPoint(touch),
pressure: Inputs.getPressure(touch),
shiftKey,
ctrlKey,
metaKey: Utils.isDarwin() ? metaKey : ctrlKey,
altKey,
}
this.pointer = info
this.activePointer = undefined
return info
}
touchMove<T extends string>(e: TouchEvent | React.TouchEvent, target: T): TLPointerInfo<T> {
const { shiftKey, ctrlKey, metaKey, altKey } = e
e.preventDefault()
const touch = e.changedTouches[0]
@ -69,7 +113,9 @@ class Inputs {
pointerDown<T extends string>(e: PointerEvent | React.PointerEvent, target: T): TLPointerInfo<T> {
const { shiftKey, ctrlKey, metaKey, altKey } = e
const point = Inputs.getPoint(e)
const point = Inputs.getPoint(e, this.offset)
this.activePointer = e.pointerId
const info: TLPointerInfo<T> = {
target,
@ -95,7 +141,7 @@ class Inputs {
): TLPointerInfo<T> {
const { shiftKey, ctrlKey, metaKey, altKey } = e
const point = Inputs.getPoint(e)
const point = Inputs.getPoint(e, this.offset)
const info: TLPointerInfo<T> = {
target,
@ -120,7 +166,7 @@ class Inputs {
const prev = this.pointer
const point = Inputs.getPoint(e)
const point = Inputs.getPoint(e, this.offset)
const delta = prev?.point ? Vec.sub(point, prev.point) : [0, 0]
@ -148,10 +194,12 @@ class Inputs {
const prev = this.pointer
const point = Inputs.getPoint(e)
const point = Inputs.getPoint(e, this.offset)
const delta = prev?.point ? Vec.sub(point, prev.point) : [0, 0]
this.activePointer = undefined
const info: TLPointerInfo<T> = {
origin: point,
...prev,
@ -182,7 +230,7 @@ class Inputs {
origin: this.pointer?.origin || [0, 0],
delta: [0, 0],
pressure: 0.5,
point: Inputs.getPoint(e),
point: Inputs.getPoint(e, this.offset),
shiftKey,
ctrlKey,
metaKey,
@ -203,7 +251,7 @@ class Inputs {
const prev = this.pointer
const point = Inputs.getPoint(e)
const point = Inputs.getPoint(e, this.offset)
const info: TLPointerInfo<'wheel'> = {
...prev,
@ -274,16 +322,14 @@ class Inputs {
pinch(point: number[], origin: number[]) {
const { shiftKey, ctrlKey, metaKey, altKey } = this.keys
const prev = this.pointer
const delta = Vec.sub(origin, point)
const info: TLPointerInfo<'pinch'> = {
pointerId: 0,
target: 'pinch',
origin: prev?.origin || Vec.round(point),
origin,
delta: delta,
point: Vec.round(point),
point: Vec.sub(Vec.round(point), this.offset),
pressure: 0.5,
shiftKey,
ctrlKey,
@ -300,13 +346,18 @@ class Inputs {
this.pointerUpTime = 0
this.pointer = undefined
this.keyboard = undefined
this.activePointer = undefined
this.keys = {}
}
static getPoint(
e: PointerEvent | React.PointerEvent | Touch | React.Touch | WheelEvent
e: PointerEvent | React.PointerEvent | Touch | React.Touch | WheelEvent,
offset = [0, 0]
): number[] {
return [Number(e.clientX.toPrecision(5)), Number(e.clientY.toPrecision(5))]
return [
Number(e.clientX.toPrecision(5)) - offset[0],
Number(e.clientY.toPrecision(5)) - offset[1],
]
}
static getPressure(e: PointerEvent | React.PointerEvent | Touch | React.Touch | WheelEvent) {

View file

@ -3,6 +3,7 @@ import type { TLPageState, TLBounds } from '../types'
import { mockDocument } from './mockDocument'
import { mockUtils } from './mockUtils'
import { useTLTheme, TLContext } from '../hooks'
import { Inputs } from '+inputs'
export const ContextWrapper: React.FC = ({ children }) => {
useTLTheme()
@ -14,6 +15,7 @@ export const ContextWrapper: React.FC = ({ children }) => {
shapeUtils: mockUtils,
rScreenBounds,
rPageState,
inputs: new Inputs(),
}))
return <TLContext.Provider value={context}>{children}</TLContext.Provider>

View file

@ -98,7 +98,13 @@ export type TLWheelEventHandler = (
) => void
export type TLPinchEventHandler = (
info: TLPointerInfo<string>,
e: React.WheelEvent<Element> | WheelEvent | React.TouchEvent<Element> | TouchEvent
e:
| React.WheelEvent<Element>
| WheelEvent
| React.TouchEvent<Element>
| TouchEvent
| React.PointerEvent<Element>
| PointerEventInit
) => void
export type TLPointerEventHandler = (info: TLPointerInfo<string>, e: React.PointerEvent) => void
export type TLCanvasEventHandler = (info: TLPointerInfo<'canvas'>, e: React.PointerEvent) => void

View file

@ -1639,7 +1639,7 @@ left past the initial left edge) then swap points on that axis.
/**
* Debounce a function.
*/
static debounce<T extends (...args: unknown[]) => void>(fn: T, ms = 0) {
static debounce<T extends (...args: any[]) => void>(fn: T, ms = 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let timeoutId: number | any
return function (...args: Parameters<T>) {
@ -1655,18 +1655,22 @@ left past the initial left edge) then swap points on that axis.
static getSvgPathFromStroke(stroke: number[][]): string {
if (!stroke.length) return ''
const max = stroke.length - 1
const d = stroke.reduce(
(acc, [x0, y0], i, arr) => {
const [x1, y1] = arr[(i + 1) % arr.length]
if (i === max) return acc
const [x1, y1] = arr[i + 1]
acc.push(` ${x0},${y0} ${(x0 + x1) / 2},${(y0 + y1) / 2}`)
return acc
},
['M ', `${stroke[0][0]},${stroke[0][1]}`, ' Q']
)
d.push(' Z')
return d.join('').replaceAll(/(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g, '$1')
return d
.concat('Z')
.join('')
.replaceAll(/(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g, '$1')
}
/* -------------------------------------------------- */
@ -1702,7 +1706,7 @@ left past the initial left edge) then swap points on that axis.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
lastResult = func.apply(this, ...args)
lastResult = func(...args)
}
return lastResult

View file

@ -3,7 +3,7 @@ import fs from 'fs'
import esbuild from 'esbuild'
import serve, { error, log } from 'create-serve'
const isDevServer = process.argv.includes('--dev')
const isDevServer = true
if (!fs.existsSync('./dist')) {
fs.mkdirSync('./dist')

View file

@ -21,17 +21,20 @@
"@tldraw/tldraw": "^0.0.85",
"idb": "^6.1.2",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"react-dom": "^17.0.2",
"react-router": "^5.2.1",
"react-router-dom": "^5.3.0"
},
"devDependencies": {
"@types/node": "^14.14.35",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.2",
"@types/react-router-dom": "^5.1.8",
"concurrently": "6.0.1",
"create-serve": "1.0.1",
"esbuild": "0.11.5",
"esbuild": "^0.12.26",
"rimraf": "3.0.2",
"typescript": "4.2.3"
},
"gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6"
}
}

View file

@ -1,8 +1,51 @@
import * as React from 'react'
import { Switch, Route, Link } from 'react-router-dom'
import Basic from './basic'
import Controlled from './controlled'
import Imperative from './imperative'
import Embedded from './embedded'
import ChangingId from './changing-id'
import './styles.css'
export default function App(): JSX.Element {
return <Basic />
return (
<main>
<Switch>
<Route path="/basic">
<Basic />
</Route>
<Route path="/controlled">
<Controlled />
</Route>
<Route path="/imperative">
<Imperative />
</Route>
<Route path="/changing-id">
<ChangingId />
</Route>
<Route path="/embedded">
<Embedded />
</Route>
<Route path="/">
<ul>
<li>
<Link to="/basic">basic</Link>
</li>
<li>
<Link to="/controlled">controlled</Link>
</li>
<li>
<Link to="/imperative">imperative</Link>
</li>
<li>
<Link to="/changing-id">changing id</Link>
</li>
<li>
<Link to="/embedded">embedded</Link>
</li>
</ul>
</Route>
</Switch>
</main>
)
}

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import Editor from './components/editor'
export default function BasicUsage(): JSX.Element {
export default function Basic(): JSX.Element {
return <Editor />
}

View file

@ -1,7 +1,7 @@
import * as React from 'react'
import { TLDraw } from '@tldraw/tldraw'
export default function NewId() {
export default function ChangingId() {
const [id, setId] = React.useState('example')
React.useEffect(() => {

View file

@ -12,5 +12,9 @@ export default function Editor(props: TLDrawProps): JSX.Element {
props.onMount?.(state)
}, [])
return <TLDraw id="tldraw" {...props} onMount={handleMount} />
return (
<div className="tldraw">
<TLDraw id="tldraw" {...props} onMount={handleMount} />
</div>
)
}

View file

@ -0,0 +1,30 @@
import { TLDraw } from '@tldraw/tldraw'
import * as React from 'react'
export default function Embedded(): JSX.Element {
return (
<div style={{ padding: '2% 10%', width: 'calc(100% - 100px)' }}>
<div
style={{
position: 'relative',
width: 'auto',
height: '500px',
overflow: 'hidden',
}}
>
<TLDraw id="small1" />
</div>
<div
style={{
position: 'relative',
width: 'auto',
height: '500px',
overflow: 'hidden',
}}
>
<TLDraw id="small2" />
</div>
</div>
)
}

View file

@ -1,100 +0,0 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import * as React from 'react'
import { openDB, DBSchema } from 'idb'
import type { TLDrawDocument } from '@tldraw/tldraw'
const VERSION = 5
interface TLDatabase extends DBSchema {
documents: {
key: string
value: TLDrawDocument
}
}
/**
* Persist a value in indexdb. This hook is designed to be used primarily through
* its methods, `setValue` and `forceUpdate`. The `setValue` method will update the
* value in the database, howeever it will NOT cause the hook's component to update.
* The `forceUpdate` method will cause the component to update with the latest value
* in the database.
*
* ### Example
*
*```ts
* const {status, value, setValue, forceUpdate} = usePersistence()
*```
*/
export function usePersistence(id: string, doc: TLDrawDocument) {
const [status, setStatus] = React.useState<'loading' | 'ready'>('loading')
const [value, _setValue] = React.useState<TLDrawDocument | null>(null)
// A function that other parts of the program can use to manually update
// the state to the latest value in the database.
const forceUpdate = React.useCallback(() => {
_setValue(null)
setStatus('loading')
openDB<TLDatabase>('db', VERSION).then((db) =>
db.get('documents', id).then((v) => {
if (!v) throw Error(`Could not find document with id: ${id}`)
_setValue(v)
setStatus('ready')
})
)
}, [id])
// A function that other parts of the program can use to manually set the
// value in the database.
const setValue = React.useCallback(
(doc: TLDrawDocument) => {
openDB<TLDatabase>('db', VERSION).then((db) => db.put('documents', doc, id))
},
[id]
)
// Whenever the id or doc changes, save the new value to the database and update
// the state.
React.useEffect(() => {
async function handleLoad() {
const db = await openDB<TLDatabase>('db', VERSION, {
upgrade(db, _oldVersion, newVersion) {
if (newVersion) {
if (db.objectStoreNames.contains('documents')) {
db.deleteObjectStore('documents')
}
db.createObjectStore('documents')
}
},
})
let savedDoc: TLDrawDocument
try {
const restoredDoc = await db.get('documents', id)
if (!restoredDoc) throw Error('No document')
savedDoc = restoredDoc
restoredDoc.pageStates = Object.fromEntries(
Object.entries(restoredDoc.pageStates).map(([pageId, pageState]) => [
pageId,
{
...pageState,
hoveredId: undefined,
editingId: undefined,
},
])
)
} catch (e) {
await db.put('documents', doc, id)
savedDoc = doc
}
_setValue(savedDoc)
setStatus('ready')
}
handleLoad()
}, [id, doc])
return { value, status, setValue, forceUpdate }
}

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="bundle.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>tldraw</title>
</head>

View file

@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'
import { HashRouter } from 'react-router-dom'
ReactDOM.render(
<React.StrictMode>
<App />
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>,
document.getElementById('root')
)

View file

@ -0,0 +1,20 @@
html,
* {
box-sizing: border-box;
}
body {
overscroll-behavior: none;
margin: 0px;
padding: 0px;
}
.tldraw {
position: fixed;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
width: 100%;
height: 100%;
}

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ import styled from '~styles'
/* -------------------------------------------------- */
export const DialogContent = styled('div', {
position: 'fixed',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',

View file

@ -1,5 +1,6 @@
import * as React from 'react'
import { render } from '@testing-library/react'
import { act } from 'react-dom/test-utils'
import { TLDraw } from './tldraw'
describe('tldraw', () => {

View file

@ -218,32 +218,19 @@ function InnerTldraw({
)
}
const Spacer = styled('div', {
flexGrow: 2,
})
const MenuButtons = styled('div', {
display: 'flex',
gap: 8,
})
const Layout = styled('main', {
position: 'fixed',
const Layout = styled('div', {
overflow: 'hidden',
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
height: '100%',
width: '100%',
padding: '8px 8px 0 8px',
zIndex: 200,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-start',
boxSizing: 'border-box',
outline: 'none',
pointerEvents: 'none',
zIndex: 1,
'& > *': {
pointerEvents: 'all',
@ -253,5 +240,17 @@ const Layout = styled('main', {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 100,
},
})
const Spacer = styled('div', {
flexGrow: 2,
})
const MenuButtons = styled('div', {
display: 'flex',
gap: 8,
})

View file

@ -139,7 +139,7 @@ export const ToolsPanel = React.memo((): JSX.Element => {
})
const ToolsPanelContainer = styled('div', {
position: 'fixed',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,

View file

@ -90,7 +90,6 @@ export class TextSession implements Session {
// if (initialShape.text.trim() === '' && shape.text.trim() === '') {
// // delete shape
// console.log('deleting shape')
// return {
// id: 'text',
// before: {

View file

@ -1005,7 +1005,7 @@ export class TLDrawState extends StateManager<Data> {
*/
pinchZoom = (point: number[], delta: number[], zoomDelta: number): this => {
const { camera } = this.pageState
const nextPoint = Vec.add(camera.point, Vec.div(delta, camera.zoom))
const nextPoint = Vec.sub(camera.point, Vec.div(delta, camera.zoom))
const nextZoom = TLDR.getCameraZoom(camera.zoom - zoomDelta * camera.zoom)
const p0 = Vec.sub(Vec.div(point, camera.zoom), nextPoint)
const p1 = Vec.sub(Vec.div(point, nextZoom), nextPoint)
@ -2227,6 +2227,9 @@ export class TLDrawState extends StateManager<Data> {
/* ------------- Renderer Event Handlers ------------ */
onPinchStart: TLPinchEventHandler = () => {
if (this.session) {
this.cancelSession()
}
this.setStatus(TLDrawStatus.Pinching)
}
@ -2236,13 +2239,13 @@ export class TLDrawState extends StateManager<Data> {
const nextZoom = TLDR.getCameraZoom(i * 0.25)
this.zoomTo(nextZoom, inputs.pointer?.point)
}
this.setStatus(this.appState.status.previous)
this.setStatus(TLDrawStatus.Idle)
}
onPinch: TLPinchEventHandler = (info) => {
if (this.appState.status.current !== TLDrawStatus.Pinching) return
this.pinchZoom(info.origin, info.delta, info.delta[2] / 350)
this.pinchZoom(info.point, info.delta, info.delta[2])
this.updateOnPointerMove(info)
}

View file

@ -1,2 +1,3 @@
import '@testing-library/jest-dom/extend-expect'
import "fake-indexeddb/auto"
import 'fake-indexeddb/auto'
global.ResizeObserver = require('resize-observer-polyfill')

991
yarn.lock

File diff suppressed because it is too large Load diff