DOM-based shape size

/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
	AtomMap,
	EditorAtom,
	RecordProps,
	Rectangle2d,
	ShapeUtil,
	T,
	TLBaseShape,
	Tldraw,
	TLShapeId,
	useEditor,
} from 'tldraw'
import 'tldraw/tldraw.css'
import { contents } from './contents'

// There's a guide at the bottom of this file!

const SHAPE_WIDTH_PX = 150

// [1]
type DynamicSizeShape = TLBaseShape<'dynamic-size', { contents: string[] }>

// [2]
const ShapeSizes = new EditorAtom('shape sizes', (editor) => {
	const map = new AtomMap<TLShapeId, { width: number; height: number }>('shape sizes')

	// [a] Clean up sizes when shapes are deleted
	editor.sideEffects.registerAfterDeleteHandler('shape', (shape) => {
		map.delete(shape.id)
	})

	return map
})

// [3]
function useDynamicShapeSize(shape: DynamicSizeShape) {
	const ref = useRef<HTMLDivElement>(null)
	const editor = useEditor()

	const updateShapeSize = useCallback(() => {
		if (!ref.current) return

		// [a] Get actual DOM dimensions
		const width = ref.current.offsetWidth
		const height = ref.current.offsetHeight

		// [b] Update the shape size in our global atom
		ShapeSizes.update(editor, (map) => {
			const existing = map.get(shape.id)
			if (existing && existing.width === width && existing.height === height) return map
			return map.set(shape.id, { width, height })
		})
	}, [editor, shape.id])

	// [c] Update size immediately on render
	useLayoutEffect(() => {
		updateShapeSize()
	})

	// [d] Watch for DOM size changes using ResizeObserver
	useLayoutEffect(() => {
		if (!ref.current) return
		const observer = new ResizeObserver(updateShapeSize)
		observer.observe(ref.current)
		return () => {
			observer.disconnect()
		}
	}, [updateShapeSize])

	return ref
}

// [4]
export class DynamicSizeShapeUtil extends ShapeUtil<DynamicSizeShape> {
	// [a]
	static override type = 'dynamic-size' as const
	static override props: RecordProps<DynamicSizeShape> = {
		contents: T.arrayOf(T.string),
	}

	// [b]
	getDefaultProps(): DynamicSizeShape['props'] {
		return {
			contents,
		}
	}

	// [c]
	override canCull() {
		return false
	}

	// [d]
	override canEdit() {
		return false
	}
	override canResize() {
		return false
	}
	override isAspectRatioLocked() {
		return true
	}

	// [e]
	getGeometry(shape: DynamicSizeShape) {
		const size = ShapeSizes.get(this.editor).get(shape.id)
		return new Rectangle2d({
			width: SHAPE_WIDTH_PX,
			height: size?.height ?? 50,
			isFilled: true,
		})
	}

	// [f]
	component(shape: DynamicSizeShape) {
		const ref = useDynamicShapeSize(shape)

		const [contentsToShow, setContentsToShow] = useState<string>('')

		// [i] Animate text content to demonstrate dynamic sizing
		useEffect(() => {
			const animationDuration = 6000
			const tick = (time: number) => {
				const progress = (time % animationDuration) / animationDuration
				const amountToShow = progress < 0.5 ? progress * 2 : 1 - (progress - 0.5) * 2

				setContentsToShow(
					shape.props.contents
						.slice(0, Math.floor(amountToShow * shape.props.contents.length))
						.join(' ')
				)

				frame = requestAnimationFrame(tick)
			}

			let frame = requestAnimationFrame(tick)

			return () => {
				cancelAnimationFrame(frame)
			}
		}, [shape.props.contents])

		// [ii] Return DOM element that will be measured
		return (
			<div ref={ref} style={{ width: SHAPE_WIDTH_PX }}>
				{contentsToShow}
			</div>
		)
	}

	// [g]
	indicator(shape: DynamicSizeShape) {
		const { width, height } = this.editor.getShapeGeometry(shape).bounds

		return <rect width={width} height={height} />
	}
}

// [5]
const shapeUtils = [DynamicSizeShapeUtil]

export default function SizeFromDomExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw
				shapeUtils={shapeUtils}
				onMount={(editor) => {
					editor.selectAll()
					editor.deleteShapes(editor.getSelectedShapeIds())

					editor.createShape<DynamicSizeShape>({
						type: 'dynamic-size',
						x: 100,
						y: 100,
					})

					editor.selectAll().zoomToSelection()
				}}
			/>
		</div>
	)
}

/*
Introduction:

This example demonstrates how to create a shape whose size is determined by its DOM content rather than
shape props. It showcases two potentially reusable utilities: ShapeSizes and useDynamicShapeSize, which
can be adapted for other shapes that need DOM-driven sizing.

[1]
Define the shape type. This shape only stores content data - its size is determined dynamically by
measuring the DOM element that renders the content.

[2] 
ShapeSizes is a global EditorAtom that stores size information for shapes by their ID. This is the key
piece that makes DOM-driven sizing work:
	
	[a] We register a cleanup handler to remove size data when shapes are deleted, preventing memory leaks.

[3]
useDynamicShapeSize is a reusable hook that measures DOM elements and updates the shape size data:

	[a] We measure the actual DOM dimensions using offsetWidth/offsetHeight
	
	[b] We store these dimensions in our global ShapeSizes atom. The atom will trigger re-renders of
	    components that depend on this data when the size changes.
	
	[c] We measure immediately on every render to ensure we have current size data
	
	[d] We use ResizeObserver to watch for size changes and update accordingly. This is what makes
	    the shape truly dynamic - it will update whenever the DOM content changes size.

[4]
The shape util defines how our dynamic-size shape behaves:

	[a] Standard shape type and props definition. Note we only store content, not size.
	
	[b] Default props with some sample content

	[c] Prevent the shape from being culled when it's outside the viewport, which would break our measurements
	
	[d] Shape behavior: not editable, not resizable (since size comes from DOM), aspect ratio locked
	
	[e] getGeometry uses the size from our ShapeSizes atom. This is where the DOM-measured size gets
	    used by the editor for hit-testing, selection bounds, etc.
	
	[f] The component renders the content and uses our hook to measure it:
	
		[i] We animate the text content to demonstrate the dynamic sizing in action
		
		[ii] The ref from useDynamicShapeSize is attached to the DOM element we want to measure
	
	[g] Standard indicator for selection outline

[5]
Standard setup - pass our custom shape util to Tldraw and create an instance on mount.

Reusability:

The ShapeSizes atom and useDynamicShapeSize hook are designed to be reusable. To use them with other
shapes, you just need to:
1. Call useDynamicShapeSize(shape) in your component and attach the returned ref
2. Use ShapeSizes.get(editor).get(shapeId) in your getGeometry method
3. Ensure your shape doesn't have conflicting size props (or handle the conflict appropriately)

*/
Is this page helpful?
Prev
Attach shapes together (bindings)
Next
Layout constraints (bindings)