diff --git a/packages/core/src/components/bounds/bounds-bg.tsx b/packages/core/src/components/bounds/bounds-bg.tsx index a994e799d..6863d7d42 100644 --- a/packages/core/src/components/bounds/bounds-bg.tsx +++ b/packages/core/src/components/bounds/bounds-bg.tsx @@ -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' diff --git a/packages/core/src/components/container/container.tsx b/packages/core/src/components/container/container.tsx index 9354cbbe6..622ad8695 100644 --- a/packages/core/src/components/container/container.tsx +++ b/packages/core/src/components/container/container.tsx @@ -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 ( -
+
{children}
) diff --git a/packages/core/src/components/page/page.tsx b/packages/core/src/components/page/page.tsx index f39b6e7f0..ee6d97977 100644 --- a/packages/core/src/components/page/page.tsx +++ b/packages/core/src/components/page/page.tsx @@ -68,13 +68,14 @@ export function Page>({ selectedIds .filter(Boolean) .map((id) => ( - + ))} {!hideIndicators && hoveredId && ( )} {!hideHandles && shapeWithHandles && } diff --git a/packages/core/src/components/shape-indicator/shape-indicator.test.tsx b/packages/core/src/components/shape-indicator/shape-indicator.test.tsx index a9b7d18c3..66b360839 100644 --- a/packages/core/src/components/shape-indicator/shape-indicator.test.tsx +++ b/packages/core/src/components/shape-indicator/shape-indicator.test.tsx @@ -5,7 +5,12 @@ import { ShapeIndicator } from './shape-indicator' describe('shape indicator', () => { test('mounts component without crashing', () => { renderWithSvg( - + ) }) }) diff --git a/packages/core/src/components/shape-indicator/shape-indicator.tsx b/packages/core/src/components/shape-indicator/shape-indicator.tsx index 0bdf5c24d..683483117 100644 --- a/packages/core/src/components/shape-indicator/shape-indicator.tsx +++ b/packages/core/src/components/shape-indicator/shape-indicator.tsx @@ -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 { + shape: T + meta: M extends any ? M : undefined + isSelected?: boolean + isHovered?: boolean +} + export const ShapeIndicator = React.memo( - ({ shape, variant }: { shape: TLShape; variant: 'selected' | 'hovered' }) => { + ({ isHovered, isSelected, shape, meta }: IndicatorProps) => { 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 (
- +
diff --git a/packages/core/src/components/shape/shape.tsx b/packages/core/src/components/shape/shape.tsx index 32b359dee..522039165 100644 --- a/packages/core/src/components/shape/shape.tsx +++ b/packages/core/src/components/shape/shape.tsx @@ -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 extends IShapeTreeNode { utils: TLShapeUtil } -export const Shape = ({ - shape, - utils, - isEditing, - isBinding, - isHovered, - isSelected, - isCurrentParent, - meta, -}: ShapeProps) => { - const { callbacks } = useTLContext() - const bounds = utils.getBounds(shape) - const events = useShapeEvents(shape.id, isCurrentParent) +export const Shape = React.memo( + ({ + shape, + utils, + isEditing, + isBinding, + isHovered, + isSelected, + isCurrentParent, + meta, + }: ShapeProps) => { + const { callbacks } = useTLContext() + const bounds = utils.getBounds(shape) + const events = useShapeEvents(shape.id, isCurrentParent) - return ( - - - - ) -} + useForceUpdate() + + return ( + + + + ) + } +) diff --git a/packages/core/src/hooks/useForceUpdate.ts b/packages/core/src/hooks/useForceUpdate.ts new file mode 100644 index 000000000..d1a206869 --- /dev/null +++ b/packages/core/src/hooks/useForceUpdate.ts @@ -0,0 +1,6 @@ +import * as React from 'react' + +export function useForceUpdate() { + const forceUpdate = React.useReducer((s) => s + 1, 0) + React.useLayoutEffect(() => forceUpdate[1](), []) +} diff --git a/packages/core/src/hooks/usePosition.ts b/packages/core/src/hooks/usePosition.ts index 0ff549a5c..702e4aaab 100644 --- a/packages/core/src/hooks/usePosition.ts +++ b/packages/core/src/hooks/usePosition.ts @@ -5,18 +5,20 @@ import type { TLBounds } from '+types' export function usePosition(bounds: TLBounds, rotation = 0) { const rBounds = React.useRef(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 } diff --git a/packages/core/src/hooks/useStyle.tsx b/packages/core/src/hooks/useStyle.tsx index 5e7415533..7b6df4a88 100644 --- a/packages/core/src/hooks/useStyle.tsx +++ b/packages/core/src/hooks/useStyle.tsx @@ -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)); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 35909fae4..8c363b63e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -113,6 +113,7 @@ export type TLWheelEventHandler = ( info: TLPointerInfo, e: React.WheelEvent | WheelEvent ) => void + export type TLPinchEventHandler = ( info: TLPointerInfo, e: @@ -123,9 +124,20 @@ export type TLPinchEventHandler = ( | React.PointerEvent | PointerEventInit ) => void + +export type TLShapeChangeHandler = ( + shape: { id: string } & Partial, + info?: K +) => void + +export type TLShapeBlurHandler = (info?: K) => void + export type TLPointerEventHandler = (info: TLPointerInfo, 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, e: React.PointerEvent @@ -188,9 +200,9 @@ export interface TLCallbacks { onReleaseHandle: TLPointerEventHandler // Misc + onShapeChange: TLShapeChangeHandler + onShapeBlur: TLShapeBlurHandler onRenderCountChange: (ids: string[]) => void - onShapeChange: (shape: { id: string } & Partial) => void - onShapeBlur: () => void onError: (error: Error) => void } @@ -287,7 +299,10 @@ export type TLShapeUtil< ref: React.ForwardedRef ): React.ReactElement, E['tagName']> - Indicator(this: TLShapeUtil, props: { shape: T }): React.ReactElement | null + Indicator( + this: TLShapeUtil, + props: { shape: T; meta: M; isHovered: boolean; isSelected: boolean } + ): React.ReactElement | null getBounds(this: TLShapeUtil, shape: T): TLBounds diff --git a/packages/dev/src/app.tsx b/packages/dev/src/app.tsx index 94e044c73..40078e5bc 100644 --- a/packages/dev/src/app.tsx +++ b/packages/dev/src/app.tsx @@ -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 { + + + @@ -31,6 +35,9 @@ export default function App(): JSX.Element {
  • basic
  • +
  • + core +
  • controlled
  • diff --git a/packages/dev/src/core/index.tsx b/packages/dev/src/core/index.tsx new file mode 100644 index 000000000..10de911d7 --- /dev/null +++ b/packages/dev/src/core/index.tsx @@ -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 ( +
    + +
    + ) +} diff --git a/packages/dev/src/core/label.tsx b/packages/dev/src/core/label.tsx new file mode 100644 index 000000000..818bf130b --- /dev/null +++ b/packages/dev/src/core/label.tsx @@ -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(() => ({ + 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(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 ( + +
    +
    isSelected && e.stopPropagation()}> +
    +
    +
    + + ) + }, + Indicator({ shape }) { + const bounds = this?.getBounds(shape) + + return ( + + ) + }, + 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) + }, +})) diff --git a/packages/dev/src/core/rectangle.tsx b/packages/dev/src/core/rectangle.tsx new file mode 100644 index 000000000..5142e6c82 --- /dev/null +++ b/packages/dev/src/core/rectangle.tsx @@ -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( + () => ({ + 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(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 ( + +
    +
    isEditing && e.stopPropagation()}> +
    +
    +
    + + ) + }, + Indicator({ shape }) { + return ( + + ) + }, + 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) + }, + }) +) diff --git a/packages/dev/src/core/state.ts b/packages/dev/src/core/state.ts new file mode 100644 index 000000000..025c5e340 --- /dev/null +++ b/packages/dev/src/core/state.ts @@ -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 + pageState: TLPageState + meta: { + isDarkMode: boolean + } +} + +class AppState extends StateManager { + /* ----------------------- 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 = (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, + }, +}) diff --git a/packages/tldraw/src/shape/shapes/draw/draw.tsx b/packages/tldraw/src/shape/shapes/draw/draw.tsx index c52f67162..510d9058a 100644 --- a/packages/tldraw/src/shape/shapes/draw/draw.tsx +++ b/packages/tldraw/src/shape/shapes/draw/draw.tsx @@ -7,10 +7,12 @@ import { defaultStyle, getShapeStyle } from '~shape/shape-styles' import { DrawShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types' const pointsBoundsCache = new WeakMap([]) +const shapeBoundsCache = new Map() const rotatedCache = new WeakMap([]) const drawPathCache = new WeakMap([]) const simplePathCache = new WeakMap([]) const polygonCache = new WeakMap([]) +const pointCache = new WeakSet([]) export const Draw = new ShapeUtil(() => ({ type: TLDrawShapeType.Draw, @@ -159,12 +161,32 @@ export const Draw = new ShapeUtil(() => ({ }, 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 { diff --git a/packages/tldraw/src/state/session/sessions/draw/draw.session.ts b/packages/tldraw/src/state/session/sessions/draw/draw.session.ts index b5c28f728..979144c7f 100644 --- a/packages/tldraw/src/state/session/sessions/draw/draw.session.ts +++ b/packages/tldraw/src/state/session/sessions/draw/draw.session.ts @@ -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[][]