Changes usePosition to useLayoutEffect (#91)

This commit is contained in:
Steve Ruiz 2021-09-15 16:47:34 +01:00 committed by GitHub
parent f5b7190010
commit 17a7b15f9a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 573 additions and 69 deletions

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TLBounds } from '+types'
import { useBoundsEvents, usePosition } from '+hooks'
import { useBoundsEvents } from '+hooks'
import { Container } from '+components/container'
import { SVGContainer } from '+components/svg-container'

View file

@ -12,10 +12,10 @@ interface ContainerProps {
export const Container = React.memo(
({ id, bounds, rotation = 0, className, children }: ContainerProps) => {
const rBounds = usePosition(bounds, rotation)
const rPositioned = usePosition(bounds, rotation)
return (
<div id={id} ref={rBounds} className={['tl-positioned', className || ''].join(' ')}>
<div id={id} ref={rPositioned} className={['tl-positioned', className || ''].join(' ')}>
{children}
</div>
)

View file

@ -68,13 +68,14 @@ export function Page<T extends TLShape, M extends Record<string, unknown>>({
selectedIds
.filter(Boolean)
.map((id) => (
<ShapeIndicator key={'selected_' + id} shape={page.shapes[id]} variant="selected" />
<ShapeIndicator key={'selected_' + id} shape={page.shapes[id]} meta={meta} isSelected />
))}
{!hideIndicators && hoveredId && (
<ShapeIndicator
key={'hovered_' + hoveredId}
shape={page.shapes[hoveredId]}
variant="hovered"
meta={meta}
isHovered
/>
)}
{!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />}

View file

@ -5,7 +5,12 @@ import { ShapeIndicator } from './shape-indicator'
describe('shape indicator', () => {
test('mounts component without crashing', () => {
renderWithSvg(
<ShapeIndicator shape={mockUtils.box.create({ id: 'box1' })} variant={'selected'} />
<ShapeIndicator
shape={mockUtils.box.create({ id: 'box1' })}
isSelected={true}
isHovered={false}
meta={undefined}
/>
)
})
})

View file

@ -1,24 +1,35 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react'
import type { TLShape } from '+types'
import { usePosition, useTLContext } from '+hooks'
interface IndicatorProps<T extends TLShape, M = any> {
shape: T
meta: M extends any ? M : undefined
isSelected?: boolean
isHovered?: boolean
}
export const ShapeIndicator = React.memo(
({ shape, variant }: { shape: TLShape; variant: 'selected' | 'hovered' }) => {
<T extends TLShape, M = any>({ isHovered, isSelected, shape, meta }: IndicatorProps<T, M>) => {
const { shapeUtils } = useTLContext()
const utils = shapeUtils[shape.type]
const bounds = utils.getBounds(shape)
const rBounds = usePosition(bounds, shape.rotation)
const rPositioned = usePosition(bounds, shape.rotation)
return (
<div
ref={rBounds}
className={
'tl-indicator tl-absolute ' + (variant === 'selected' ? 'tl-selected' : 'tl-hovered')
}
ref={rPositioned}
className={'tl-indicator tl-absolute ' + (isSelected ? 'tl-selected' : 'tl-hovered')}
>
<svg width="100%" height="100%">
<g className="tl-centered-g">
<utils.Indicator shape={shape} />
<utils.Indicator
shape={shape}
meta={meta}
isSelected={isSelected}
isHovered={isHovered}
/>
</g>
</svg>
</div>

View file

@ -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 { useShapeEvents } from '+hooks'
@ -5,39 +6,44 @@ import type { IShapeTreeNode, TLShape, TLShapeUtil } from '+types'
import { RenderedShape } from './rendered-shape'
import { Container } from '+components/container'
import { useTLContext } from '+hooks'
import { useForceUpdate } from '+hooks/useForceUpdate'
interface ShapeProps<T extends TLShape, E extends Element, M> extends IShapeTreeNode<T, M> {
utils: TLShapeUtil<T, E, M>
}
export const Shape = <T extends TLShape, E extends Element, M>({
shape,
utils,
isEditing,
isBinding,
isHovered,
isSelected,
isCurrentParent,
meta,
}: ShapeProps<T, E, M>) => {
const { callbacks } = useTLContext()
const bounds = utils.getBounds(shape)
const events = useShapeEvents(shape.id, isCurrentParent)
export const Shape = React.memo(
<T extends TLShape, E extends Element, M>({
shape,
utils,
isEditing,
isBinding,
isHovered,
isSelected,
isCurrentParent,
meta,
}: ShapeProps<T, E, M>) => {
const { callbacks } = useTLContext()
const bounds = utils.getBounds(shape)
const events = useShapeEvents(shape.id, isCurrentParent)
return (
<Container id={shape.id} className="tl-shape" bounds={bounds} rotation={shape.rotation}>
<RenderedShape
shape={shape}
isBinding={isBinding}
isCurrentParent={isCurrentParent}
isEditing={isEditing}
isHovered={isHovered}
isSelected={isSelected}
utils={utils as any}
meta={meta}
events={events}
onShapeChange={callbacks.onShapeChange}
/>
</Container>
)
}
useForceUpdate()
return (
<Container id={shape.id} className="tl-shape" bounds={bounds} rotation={shape.rotation}>
<RenderedShape
shape={shape}
isBinding={isBinding}
isCurrentParent={isCurrentParent}
isEditing={isEditing}
isHovered={isHovered}
isSelected={isSelected}
utils={utils as any}
meta={meta}
events={events}
onShapeChange={callbacks.onShapeChange}
/>
</Container>
)
}
)

View file

@ -0,0 +1,6 @@
import * as React from 'react'
export function useForceUpdate() {
const forceUpdate = React.useReducer((s) => s + 1, 0)
React.useLayoutEffect(() => forceUpdate[1](), [])
}

View file

@ -5,18 +5,20 @@ import type { TLBounds } from '+types'
export function usePosition(bounds: TLBounds, rotation = 0) {
const rBounds = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
React.useLayoutEffect(() => {
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])
}, [bounds, rotation])
return rBounds
}

View file

@ -150,6 +150,7 @@ const tlcss = css`
left: 0;
height: 0;
width: 0;
contain: layout size;
transform: scale(var(--tl-zoom), var(--tl-zoom))
translate(var(--tl-camera-x), var(--tl-camera-y));
}
@ -171,6 +172,7 @@ const tlcss = css`
align-items: center;
justify-content: center;
overflow: clip;
contain: layout size paint;
}
.tl-positioned-svg {
@ -319,18 +321,6 @@ const tlcss = css`
stroke: var(--tl-selected);
}
.tl-shape {
outline: none;
}
.tl-shape > *[data-shy='true'] {
opacity: 0;
}
.tl-shape:hover > *[data-shy='true'] {
opacity: 1;
}
.tl-centered-g {
transform: translate(var(--tl-padding), var(--tl-padding));
}

View file

@ -113,6 +113,7 @@ export type TLWheelEventHandler = (
info: TLPointerInfo<string>,
e: React.WheelEvent<Element> | WheelEvent
) => void
export type TLPinchEventHandler = (
info: TLPointerInfo<string>,
e:
@ -123,9 +124,20 @@ export type TLPinchEventHandler = (
| React.PointerEvent<Element>
| PointerEventInit
) => void
export type TLShapeChangeHandler<T, K = any> = (
shape: { id: string } & Partial<T>,
info?: K
) => void
export type TLShapeBlurHandler<K = any> = (info?: K) => void
export type TLPointerEventHandler = (info: TLPointerInfo<string>, e: React.PointerEvent) => void
export type TLCanvasEventHandler = (info: TLPointerInfo<'canvas'>, e: React.PointerEvent) => void
export type TLBoundsEventHandler = (info: TLPointerInfo<'bounds'>, e: React.PointerEvent) => void
export type TLBoundsHandleEventHandler = (
info: TLPointerInfo<TLBoundsCorner | TLBoundsEdge | 'rotate'>,
e: React.PointerEvent
@ -188,9 +200,9 @@ export interface TLCallbacks<T extends TLShape> {
onReleaseHandle: TLPointerEventHandler
// Misc
onShapeChange: TLShapeChangeHandler<T, any>
onShapeBlur: TLShapeBlurHandler<any>
onRenderCountChange: (ids: string[]) => void
onShapeChange: (shape: { id: string } & Partial<T>) => void
onShapeBlur: () => void
onError: (error: Error) => void
}
@ -287,7 +299,10 @@ export type TLShapeUtil<
ref: React.ForwardedRef<E>
): React.ReactElement<TLRenderInfo<T, E, M>, E['tagName']>
Indicator(this: TLShapeUtil<T, E, M>, props: { shape: T }): React.ReactElement | null
Indicator(
this: TLShapeUtil<T, E, M>,
props: { shape: T; meta: M; isHovered: boolean; isSelected: boolean }
): React.ReactElement | null
getBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds

View file

@ -5,6 +5,7 @@ import Controlled from './controlled'
import Imperative from './imperative'
import Embedded from './embedded'
import ChangingId from './changing-id'
import Core from './core'
import './styles.css'
export default function App(): JSX.Element {
@ -14,6 +15,9 @@ export default function App(): JSX.Element {
<Route path="/basic">
<Basic />
</Route>
<Route path="/core">
<Core />
</Route>
<Route path="/controlled">
<Controlled />
</Route>
@ -31,6 +35,9 @@ export default function App(): JSX.Element {
<li>
<Link to="/basic">basic</Link>
</li>
<li>
<Link to="/core">core</Link>
</li>
<li>
<Link to="/controlled">controlled</Link>
</li>

View file

@ -0,0 +1,35 @@
import * as React from 'react'
import { Renderer } from '@tldraw/core'
import { Rectangle } from './rectangle'
import { Label } from './label'
import { appState } from './state'
const shapeUtils: any = {
rectangle: Rectangle,
label: Label,
}
export default function Core() {
const page = appState.useStore((s) => s.page)
const pageState = appState.useStore((s) => s.pageState)
const meta = appState.useStore((s) => s.meta)
return (
<div className="tldraw">
<Renderer
shapeUtils={shapeUtils}
page={page}
pageState={pageState}
meta={meta}
onDoubleClickBounds={appState.onDoubleClickBounds}
onDoubleClickShape={appState.onDoubleClickShape}
onPointShape={appState.onPointShape}
onPointCanvas={appState.onPointCanvas}
onPointerDown={appState.onPointerDown}
onPointerMove={appState.onPointerMove}
onPointerUp={appState.onPointerUp}
onShapeChange={appState.onShapeChange}
/>
</div>
)
}

View file

@ -0,0 +1,128 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* refresh-reset */
import * as React from 'react'
import { TLShape, Utils, TLBounds, ShapeUtil, HTMLContainer } from '@tldraw/core'
import { appState } from './state'
// Define a custom shape
export interface LabelShape extends TLShape {
type: 'label'
text: string
}
// Create a "shape utility" class that interprets that shape
export const Label = new ShapeUtil<LabelShape, HTMLDivElement, { isDarkMode: boolean }>(() => ({
type: 'label',
defaultProps: {
id: 'example1',
type: 'label',
parentId: 'page1',
childIndex: 0,
name: 'Example Shape',
point: [0, 0],
rotation: 0,
text: 'Hello world!',
},
Component({ shape, events, meta, onShapeChange, isSelected }, ref) {
const color = meta.isDarkMode ? 'white' : 'black'
const bounds = this.getBounds(shape)
const rInput = React.useRef<HTMLDivElement>(null)
function updateShapeSize() {
const elm = rInput.current!
appState.changeShapeText(shape.id, elm.innerText)
onShapeChange?.({
id: shape.id,
text: elm.innerText,
})
}
React.useLayoutEffect(() => {
const elm = rInput.current!
elm.innerText = shape.text
updateShapeSize()
const observer = new MutationObserver(updateShapeSize)
observer.observe(elm, {
attributes: true,
characterData: true,
subtree: true,
})
}, [])
return (
<HTMLContainer>
<div
{...events}
style={{
width: bounds.width,
height: bounds.height,
pointerEvents: 'all',
display: 'flex',
fontSize: 20,
fontFamily: 'sans-serif',
alignItems: 'center',
justifyContent: 'center',
border: `2px solid ${color}`,
color,
}}
>
<div ref={ref} onPointerDown={(e) => isSelected && e.stopPropagation()}>
<div
ref={rInput}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textAlign: 'center',
outline: 'none',
userSelect: isSelected ? 'all' : 'none',
}}
contentEditable={isSelected}
/>
</div>
</div>
</HTMLContainer>
)
},
Indicator({ shape }) {
const bounds = this?.getBounds(shape)
return (
<rect
fill="none"
stroke="blue"
strokeWidth={1}
width={bounds.width}
height={bounds.height}
pointerEvents="none"
/>
)
},
getBounds(shape) {
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
const ref = this.getRef(shape)
const width = ref.current?.offsetWidth || 0
const height = ref.current?.offsetHeight || 0
return {
minX: 0,
maxX: width,
minY: 0,
maxY: height,
width,
height,
} as TLBounds
})
return Utils.translateBounds(bounds, shape.point)
},
}))

View file

@ -0,0 +1,133 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* refresh-reset */
import * as React from 'react'
import { TLShape, Utils, TLBounds, ShapeUtil, HTMLContainer } from '@tldraw/core'
// Define a custom shape
export interface RectangleShape extends TLShape {
type: 'rectangle'
size: number[]
text: string
}
// Create a "shape utility" class that interprets that shape
export const Rectangle = new ShapeUtil<RectangleShape, HTMLDivElement, { isDarkMode: boolean }>(
() => ({
type: 'rectangle',
defaultProps: {
id: 'example1',
type: 'rectangle',
parentId: 'page1',
childIndex: 0,
name: 'Example Shape',
point: [0, 0],
size: [100, 100],
rotation: 0,
text: 'Hello world!',
},
Component({ shape, events, meta, onShapeChange, isEditing }, ref) {
const color = meta.isDarkMode ? 'white' : 'black'
const rInput = React.useRef<HTMLDivElement>(null)
function updateShapeSize() {
const elm = rInput.current!
onShapeChange?.({
...shape,
text: elm.innerText,
size: [elm.offsetWidth + 44, elm.offsetHeight + 44],
})
}
React.useLayoutEffect(() => {
const elm = rInput.current!
const observer = new MutationObserver(updateShapeSize)
observer.observe(elm, {
attributes: true,
characterData: true,
subtree: true,
})
elm.innerText = shape.text
updateShapeSize()
return () => {
observer.disconnect()
}
}, [])
React.useEffect(() => {
if (isEditing) {
rInput.current!.focus()
}
}, [isEditing])
return (
<HTMLContainer ref={ref}>
<div
{...events}
style={{
pointerEvents: 'all',
width: shape.size[0],
height: shape.size[1],
display: 'flex',
fontSize: 20,
fontFamily: 'sans-serif',
alignItems: 'center',
justifyContent: 'center',
border: `2px solid ${color}`,
color,
}}
>
<div onPointerDown={(e) => isEditing && e.stopPropagation()}>
<div
ref={rInput}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textAlign: 'center',
outline: 'none',
userSelect: isEditing ? 'all' : 'none',
}}
contentEditable={isEditing}
/>
</div>
</div>
</HTMLContainer>
)
},
Indicator({ shape }) {
return (
<rect
fill="none"
stroke="blue"
strokeWidth={1}
width={shape.size[0]}
height={shape.size[1]}
pointerEvents="none"
/>
)
},
getBounds(shape) {
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
const [width, height] = shape.size
return {
minX: 0,
maxX: width,
minY: 0,
maxY: height,
width,
height,
} as TLBounds
})
return Utils.translateBounds(bounds, shape.point)
},
})
)

View file

@ -0,0 +1,143 @@
import type {
TLBinding,
TLPage,
TLPageState,
TLPointerEventHandler,
TLShapeChangeHandler,
} from '@tldraw/core'
import type { RectangleShape } from './rectangle'
import type { LabelShape } from './label'
import { StateManager } from 'rko'
type Shapes = RectangleShape | LabelShape
interface State {
page: TLPage<Shapes, TLBinding>
pageState: TLPageState
meta: {
isDarkMode: boolean
}
}
class AppState extends StateManager<State> {
/* ----------------------- API ---------------------- */
selectShape(shapeId: string) {
this.patchState({
pageState: {
selectedIds: [shapeId],
},
})
}
deselect() {
this.patchState({
pageState: {
selectedIds: [],
editingId: undefined,
},
})
}
startEditingShape(shapeId: string) {
this.patchState({
pageState: {
selectedIds: [shapeId],
editingId: shapeId,
},
})
}
changeShapeText = (id: string, text: string) => {
this.patchState({
page: {
shapes: {
[id]: { text },
},
},
})
}
/* --------------------- Events --------------------- */
onPointCanvas: TLPointerEventHandler = (info) => {
this.deselect()
}
onPointShape: TLPointerEventHandler = (info) => {
this.selectShape(info.target)
}
onDoubleClickShape: TLPointerEventHandler = (info) => {
this.startEditingShape(info.target)
}
onDoubleClickBounds: TLPointerEventHandler = (info) => {
// Todo
}
onPointerDown: TLPointerEventHandler = (info) => {
// Todo
}
onPointerUp: TLPointerEventHandler = (info) => {
// Todo
}
onPointerMove: TLPointerEventHandler = (info) => {
// Todo
}
onShapeChange: TLShapeChangeHandler<Shapes> = (shape) => {
if (shape.type === 'rectangle' && shape.size) {
this.patchState({
page: {
shapes: {
[shape.id]: { ...shape, size: [...shape.size] },
},
},
})
}
}
}
export const appState = new AppState({
page: {
id: 'page1',
shapes: {
rect1: {
id: 'rect1',
parentId: 'page1',
name: 'Rectangle',
childIndex: 1,
type: 'rectangle',
point: [0, 0],
rotation: 0,
size: [100, 100],
text: 'Hello world!',
},
label1: {
id: 'label1',
parentId: 'page1',
name: 'Label',
childIndex: 2,
type: 'label',
point: [200, 200],
rotation: 0,
text: 'Hello world!',
},
},
bindings: {},
},
pageState: {
id: 'page1',
selectedIds: [],
camera: {
point: [0, 0],
zoom: 1,
},
},
meta: {
isDarkMode: false,
},
})

View file

@ -7,10 +7,12 @@ import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { DrawShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
const pointsBoundsCache = new WeakMap<DrawShape['points'], TLBounds>([])
const shapeBoundsCache = new Map<string, TLBounds>()
const rotatedCache = new WeakMap<DrawShape, number[][]>([])
const drawPathCache = new WeakMap<DrawShape['points'], string>([])
const simplePathCache = new WeakMap<DrawShape['points'], string>([])
const polygonCache = new WeakMap<DrawShape['points'], string>([])
const pointCache = new WeakSet<DrawShape['point']>([])
export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Draw,
@ -159,12 +161,32 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
},
getBounds(shape: DrawShape): TLBounds {
return Utils.translateBounds(
Utils.getFromCache(pointsBoundsCache, shape.points, () =>
Utils.getBoundsFromPoints(shape.points)
),
shape.point
)
// The goal here is to avoid recalculating the bounds from the
// points array, which is expensive. However, we still need a
// new bounds if the point has changed, but we will reuse the
// previous bounds-from-points result if we can.
const pointsHaveChanged = !pointsBoundsCache.has(shape.points)
const pointHasChanged = !pointCache.has(shape.point)
if (pointsHaveChanged) {
// If the points have changed, then bust the points cache
const bounds = Utils.getBoundsFromPoints(shape.points)
pointsBoundsCache.set(shape.points, bounds)
shapeBoundsCache.set(shape.id, Utils.translateBounds(bounds, shape.point))
pointCache.add(shape.point)
} else if (pointHasChanged && !pointsHaveChanged) {
// If the point have has changed, then bust the point cache
pointCache.add(shape.point)
shapeBoundsCache.set(
shape.id,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Utils.translateBounds(pointsBoundsCache.get(shape.points)!, shape.point)
)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return shapeBoundsCache.get(shape.id)!
},
shouldRender(prev: DrawShape, next: DrawShape): boolean {

View file

@ -90,6 +90,9 @@ export class DrawSession implements Session {
// Don't add duplicate points.
if (Vec.isEqual(this.last, newPoint)) return
// Add the new adjusted point to the points array
this.points.push(newPoint)
// The new adjusted point is now the previous adjusted point.
this.last = newPoint
@ -100,9 +103,6 @@ export class DrawSession implements Session {
const delta = Vec.sub(topLeft, this.origin)
// Add the new adjusted point to the points array
this.points.push(newPoint)
// Time to shift some points!
let points: number[][]