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[][]