Camera options

Configure the camera's constraints, zoom steps, and wheel behavior, and change them live.

import { useEffect } from 'react'
import {
	BoxModel,
	TLCameraOptions,
	Tldraw,
	Vec,
	clamp,
	track,
	useEditor,
	useLocalStorageState,
} from 'tldraw'
import 'tldraw/tldraw.css'

const CAMERA_OPTIONS: TLCameraOptions = {
	isLocked: false,
	wheelBehavior: 'pan',
	panSpeed: 1,
	zoomSpeed: 1,
	zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
	constraints: {
		initialZoom: 'fit-max',
		baseZoom: 'fit-max',
		bounds: {
			x: 0,
			y: 0,
			w: 1600,
			h: 900,
		},
		behavior: { x: 'contain', y: 'contain' },
		padding: { x: 100, y: 100 },
		origin: { x: 0.5, y: 0.5 },
	},
}

const BOUNDS_SIZES: Record<string, BoxModel> = {
	a4: { x: 0, y: 0, w: 1050, h: 1485 },
	landscape: { x: 0, y: 0, w: 1600, h: 900 },
	portrait: { x: 0, y: 0, w: 900, h: 1600 },
	square: { x: 0, y: 0, w: 900, h: 900 },
}

export default function CameraOptionsExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw components={components} options={{ camera: CAMERA_OPTIONS }}>
				<CameraOptionsControlPanel />
			</Tldraw>
		</div>
	)
}

const PaddingDisplay = track(() => {
	const editor = useEditor()
	const cameraOptions = editor.getCameraOptions()

	if (!cameraOptions.constraints) return null

	const {
		constraints: {
			padding: { x: px, y: py },
		},
	} = cameraOptions

	return (
		<div
			style={{
				position: 'absolute',
				top: py,
				left: px,
				width: `calc(100% - ${px * 2}px)`,
				height: `calc(100% - ${py * 2}px)`,
				border: '1px dotted var(--tl-color-text)',
				pointerEvents: 'none',
			}}
		/>
	)
})

const BoundsDisplay = track(() => {
	const editor = useEditor()
	const cameraOptions = editor.getCameraOptions()

	if (!cameraOptions.constraints) return null

	const {
		constraints: {
			bounds: { x, y, w, h },
		},
	} = cameraOptions

	const d = Vec.ToAngle({ x: w, y: h }) * (180 / Math.PI)
	const colB = '#00000002'
	const colA = '#0000001F'

	return (
		<>
			<div
				style={{
					position: 'absolute',
					top: y,
					left: x,
					width: w,
					height: h,
					border: '1px dashed var(--tl-color-text)',
				}}
			>
				{/* grey and white stripes */}
				<div
					style={{
						position: 'absolute',
						top: 0,
						left: 0,
						width: '100%',
						height: '100%',
						backgroundImage: `
						linear-gradient(0deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%),
						linear-gradient(90deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%),
						linear-gradient(${d}deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%),
						linear-gradient(-${d}deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%)`,
					}}
				></div>
			</div>
		</>
	)
})

// [1]
const components = {
	OnTheCanvas: BoundsDisplay,
	InFrontOfTheCanvas: PaddingDisplay,
}

const CameraOptionsControlPanel = track(() => {
	const editor = useEditor()

	// [2]
	const [cameraOptions, setCameraOptions] = useLocalStorageState('camera ex1', CAMERA_OPTIONS)

	// [3]
	useEffect(() => {
		editor.run(() => {
			editor.setCameraOptions(cameraOptions)
			editor.setCamera(editor.getCamera(), {
				immediate: true,
			})
		})
	}, [editor, cameraOptions])

	const { constraints } = cameraOptions

	const updateOptions = (
		options: Partial<
			Omit<TLCameraOptions, 'constraints'> & {
				constraints: Partial<TLCameraOptions['constraints']>
			}
		>
	) => {
		const { constraints } = options
		const cameraOptions = editor.getCameraOptions()
		setCameraOptions({
			...cameraOptions,
			...options,
			constraints:
				constraints === undefined
					? cameraOptions.constraints
					: {
							...(cameraOptions.constraints! ?? CAMERA_OPTIONS.constraints),
							...constraints,
						},
		})
	}

	return (
		<div
			style={{
				pointerEvents: 'all',
				position: 'absolute',
				top: 50,
				left: 0,
				padding: 4,
				background: 'white',
				zIndex: 100,
			}}
		>
			<div
				style={{
					display: 'grid',
					gridTemplateColumns: 'auto 1fr',
					columnGap: 12,
					rowGap: 4,
					marginBottom: 12,
					alignItems: 'center',
					justifyContent: 'center',
				}}
			>
				<label htmlFor="lock">Lock</label>
				<select
					name="lock"
					value={cameraOptions.isLocked ? 'true' : 'false'}
					onChange={(e) => {
						const value = e.target.value
						updateOptions({
							...CAMERA_OPTIONS,
							isLocked: value === 'true',
						})
					}}
				>
					<option value="true">true</option>
					<option value="false">false</option>
				</select>
				<label htmlFor="wheelBehavior">Wheel behavior</label>
				<select
					name="wheelBehavior"
					value={cameraOptions.wheelBehavior}
					onChange={(e) => {
						const value = e.target.value
						updateOptions({
							...CAMERA_OPTIONS,
							wheelBehavior: value as TLCameraOptions['wheelBehavior'],
						})
					}}
				>
					<option>zoom</option>
					<option>pan</option>
					<option>none</option>
				</select>
				<label htmlFor="panspeed">Pan speed</label>
				<input
					name="panspeed"
					type="number"
					step={0.1}
					value={cameraOptions.panSpeed}
					onChange={(e) => {
						const val = clamp(Number(e.target.value), 0, 2)
						updateOptions({ panSpeed: val })
					}}
				/>
				<label htmlFor="zoomspeed">Zoom speed</label>
				<input
					name="zoomspeed"
					type="number"
					step={0.1}
					value={cameraOptions.zoomSpeed}
					onChange={(e) => {
						const val = clamp(Number(e.target.value), 0, 2)
						updateOptions({ zoomSpeed: val })
					}}
				/>
				<label htmlFor="zoomsteps">Zoom steps</label>
				<input
					name="zoomsteps"
					type="text"
					defaultValue={cameraOptions.zoomSteps.join(', ')}
					onChange={(e) => {
						try {
							const val = e.target.value.split(', ').map((v) => Number(v))
							if (val.every((v) => typeof v === 'number' && Number.isFinite(v))) {
								updateOptions({ zoomSteps: val })
							}
						} catch {
							// ignore
						}
					}}
				/>
				<label htmlFor="bounds">Bounds</label>
				<select
					name="bounds"
					value={
						Object.entries(BOUNDS_SIZES).find(([_, b]) => b.w === constraints?.bounds.w)?.[0] ??
						'none'
					}
					onChange={(e) => {
						const currentConstraints = constraints ?? CAMERA_OPTIONS.constraints
						const value = e.target.value

						if (value === 'none') {
							updateOptions({
								...CAMERA_OPTIONS,
								constraints: undefined,
							})
							return
						}

						updateOptions({
							...CAMERA_OPTIONS,
							constraints: {
								...currentConstraints,
								bounds: BOUNDS_SIZES[value] ?? BOUNDS_SIZES.a4,
							},
						})
					}}
				>
					<option value="none">none</option>
					<option value="a4">A4 page</option>
					<option value="portrait">Portrait</option>
					<option value="landscape">Landscape</option>
					<option value="square">Square</option>
				</select>
				{constraints ? (
					<>
						<label htmlFor="initialZoom">Initial zoom</label>
						<select
							name="initialZoom"
							value={constraints.initialZoom}
							onChange={(e) => {
								updateOptions({
									constraints: {
										...constraints,
										initialZoom: e.target.value as any,
									},
								})
							}}
						>
							<option>fit-min</option>
							<option>fit-max</option>
							<option>fit-x</option>
							<option>fit-y</option>
							<option>fit-min-100</option>
							<option>fit-max-100</option>
							<option>fit-x-100</option>
							<option>fit-y-100</option>
							<option>default</option>
						</select>
						<label htmlFor="zoomBehavior">Base zoom</label>
						<select
							name="zoomBehavior"
							value={constraints.baseZoom}
							onChange={(e) => {
								updateOptions({
									constraints: {
										...constraints,
										baseZoom: e.target.value as any,
									},
								})
							}}
						>
							<option>fit-min</option>
							<option>fit-max</option>
							<option>fit-x</option>
							<option>fit-y</option>
							<option>fit-min-100</option>
							<option>fit-max-100</option>
							<option>fit-x-100</option>
							<option>fit-y-100</option>
							<option>default</option>
						</select>
						<label htmlFor="originX">Origin x</label>
						<input
							name="originX"
							type="number"
							step={0.1}
							value={constraints.origin.x}
							onChange={(e) => {
								const val = clamp(Number(e.target.value), 0, 1)
								updateOptions({
									constraints: {
										origin: {
											...constraints.origin,
											x: val,
										},
									},
								})
							}}
						/>
						<label htmlFor="originY">Origin y</label>
						<input
							name="originY"
							type="number"
							step={0.1}
							value={constraints.origin.y}
							onChange={(e) => {
								const val = clamp(Number(e.target.value), 0, 1)
								updateOptions({
									constraints: {
										...constraints,
										origin: {
											...constraints.origin,
											y: val,
										},
									},
								})
							}}
						/>
						<label htmlFor="paddingX">Padding x</label>
						<input
							name="paddingX"
							type="number"
							step={10}
							value={constraints.padding.x}
							onChange={(e) => {
								const val = clamp(Number(e.target.value), 0)
								updateOptions({
									constraints: {
										...constraints,
										padding: {
											...constraints.padding,
											x: val,
										},
									},
								})
							}}
						/>
						<label htmlFor="paddingY">Padding y</label>
						<input
							name="paddingY"
							type="number"
							step={10}
							value={constraints.padding.y}
							onChange={(e) => {
								const val = clamp(Number(e.target.value), 0)
								updateOptions({
									constraints: {
										padding: {
											...constraints.padding,
											y: val,
										},
									},
								})
							}}
						/>
						<label htmlFor="behaviorX">Behavior x</label>
						<select
							name="behaviorX"
							value={(constraints.behavior as { x: any; y: any }).x}
							onChange={(e) => {
								setCameraOptions({
									...cameraOptions,
									constraints: {
										...constraints,
										behavior: {
											...(constraints.behavior as { x: any; y: any }),
											x: e.target.value as any,
										},
									},
								})
							}}
						>
							<option>free</option>
							<option>contain</option>
							<option>inside</option>
							<option>outside</option>
							<option>fixed</option>
						</select>
						<label htmlFor="behaviorY">Behavior y</label>
						<select
							name="behaviorY"
							value={(constraints.behavior as { x: any; y: any }).y}
							onChange={(e) => {
								setCameraOptions({
									...cameraOptions,
									constraints: {
										...constraints,
										behavior: {
											...(constraints.behavior as { x: any; y: any }),
											y: e.target.value as any,
										},
									},
								})
							}}
						>
							<option>free</option>
							<option>contain</option>
							<option>inside</option>
							<option>outside</option>
							<option>fixed</option>
						</select>
					</>
				) : null}
			</div>
			<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
				<button
					onClick={() => {
						editor.setCamera(editor.getCamera(), { reset: true })
					}}
				>
					Reset camera
				</button>
				<button
					onClick={() => {
						updateOptions(CAMERA_OPTIONS)
					}}
				>
					Reset camera options
				</button>
			</div>
		</div>
	)
})

/*
[1]
These two components are only there to visualize the configuration: BoundsDisplay draws the
constraint bounds in page space (OnTheCanvas), and PaddingDisplay draws the padding inset in
screen space (InFrontOfTheCanvas). Neither is needed to make constraints work.

[2]
The panel's options are kept in local storage so they survive a refresh, and so the panel,
not the initial `options.camera`, is the source of truth once it has mounted.

[3]
`editor.setCameraOptions()` updates the options at runtime and re-applies the constraints to
the current camera. We follow it with `setCamera(getCamera(), { immediate: true })` so the
camera snaps to its new constrained position instead of animating there. Wrapping both in
`editor.run()` keeps them in one transaction.
*/

The Tldraw component takes its initial camera configuration from options={{ camera: { ... } }}, a TLCameraOptions object. It controls whether the camera is locked, what the mouse wheel does, pan and zoom speed, the zoom steps, and (optionally) constraints that keep the camera inside a bounded area with a chosen fit behavior, padding, and origin.

The control panel in this example changes the options at runtime with editor.setCameraOptions() so you can see the effect of each setting. The dashed rectangle on the canvas is the constraint bounds; the dotted rectangle in front of the canvas is the padding.

Try switching bounds between "A4 page" and "landscape", then change behavior from contain to inside or outside and pan around. "Reset camera" re-fits the camera to the current constraints; "Reset camera options" restores the defaults.

Is this page helpful?
Prev
Shape options
Next
Asset options