Slideshow (fixed camera)

A slideshow that locks the camera to the current slide using camera constraints.

import { useEffect, useState } from 'react'
import { Editor, TLFrameShape, Tldraw, createShapeId, useEditor, useValue } from 'tldraw'
import 'tldraw/tldraw.css'
import { SLIDE_MARGIN, SLIDE_SIZE, SlidesProvider, useSlides } from './SlidesManager'

export default function SlideShowExample() {
	return (
		<div className="tldraw__editor">
			<SlidesProvider>
				<InsideSlidesContext />
			</SlidesProvider>
		</div>
	)
}

function InsideSlidesContext() {
	const [editor, setEditor] = useState<Editor | null>(null)
	const slides = useSlides()

	const currentSlide = useValue('currentSlide', () => slides.getCurrentSlide(), [slides])

	// [1]
	useEffect(() => {
		if (!editor) return

		const nextBounds = {
			x: currentSlide.index * (SLIDE_SIZE.w + SLIDE_MARGIN),
			y: 0,
			w: SLIDE_SIZE.w,
			h: SLIDE_SIZE.h,
		}

		editor.setCameraOptions({
			constraints: {
				bounds: nextBounds,
				behavior: 'contain',
				initialZoom: 'fit-max',
				baseZoom: 'fit-max',
				origin: { x: 0.5, y: 0.5 },
				padding: { x: 50, y: 50 },
			},
		})

		editor.zoomToBounds(nextBounds, { force: true, animation: { duration: 500 } })
	}, [editor, currentSlide])

	const currentSlides = useValue('slides', () => slides.getCurrentSlides(), [slides])

	// [2]
	useEffect(() => {
		if (!editor) return

		const ids = currentSlides.map((slide) => createShapeId(slide.id))

		editor.run(() => {
			for (let i = 0; i < currentSlides.length; i++) {
				const shapeId = ids[i]
				const slide = currentSlides[i]
				const shape = editor.getShape(shapeId)
				if (shape) {
					if (shape.x === slide.index * (SLIDE_SIZE.w + SLIDE_MARGIN)) continue

					// Renumber the frame unless the user has renamed it
					let name = (shape as TLFrameShape).props.name
					if (/Slide \d+/.test(name)) {
						name = `Slide ${slide.index + 1}`
					}

					editor.updateShape({
						id: shapeId,
						type: 'frame',
						x: slide.index * (SLIDE_SIZE.w + SLIDE_MARGIN),
						props: {
							name,
						},
					})
				} else {
					editor.createShape({
						id: shapeId,
						parentId: editor.getCurrentPageId(),
						type: 'frame',
						x: slide.index * (SLIDE_SIZE.w + SLIDE_MARGIN),
						y: 0,
						props: {
							name: `Slide ${slide.index + 1}`,
							w: SLIDE_SIZE.w,
							h: SLIDE_SIZE.h,
						},
					})
				}
			}
		})

		// [3]
		const unsubs: (() => void)[] = []

		unsubs.push(
			editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
				if (
					ids.includes(next.id) &&
					(next as TLFrameShape).props.name === (prev as TLFrameShape).props.name
				)
					return prev
				return next
			})
		)

		unsubs.push(
			editor.sideEffects.registerBeforeChangeHandler('instance_page_state', (prev, next) => {
				next.selectedShapeIds = next.selectedShapeIds.filter((id) => !ids.includes(id))
				if (next.hoveredShapeId && ids.includes(next.hoveredShapeId)) next.hoveredShapeId = null
				return next
			})
		)

		return () => {
			unsubs.forEach((fn) => fn())
		}
	}, [currentSlides, editor])

	const handleMount = (editor: Editor) => {
		setEditor(editor)
	}

	return <Tldraw onMount={handleMount} components={components} />
}

function Slides() {
	const editor = useEditor()
	const slides = useSlides()
	const currentSlides = useValue('slides', () => slides.getCurrentSlides(), [slides])
	const lowestIndex = currentSlides[0].index
	const highestIndex = currentSlides[currentSlides.length - 1].index

	// [4]
	return (
		<>
			{currentSlides.slice(0, -1).map((slide) => (
				<button
					key={slide.id + 'between'}
					style={{
						position: 'absolute',
						top: SLIDE_SIZE.h / 2,
						left: (slide.index + 1) * (SLIDE_SIZE.w + SLIDE_MARGIN) - (SLIDE_MARGIN + 40) / 2,
						width: 40,
						height: 40,
						pointerEvents: 'all',
					}}
					onPointerDown={editor.markEventAsHandled}
					onClick={() => {
						const newSlide = slides.newSlide(slide.index + 1)
						slides.setCurrentSlide(newSlide.id)
					}}
				>
					|
				</button>
			))}
			<button
				style={{
					position: 'absolute',
					top: SLIDE_SIZE.h / 2,
					left: lowestIndex * (SLIDE_SIZE.w + SLIDE_MARGIN) - (40 + SLIDE_MARGIN * 0.1),
					width: 40,
					height: 40,
					pointerEvents: 'all',
				}}
				onPointerDown={editor.markEventAsHandled}
				onClick={() => {
					const slide = slides.newSlide(lowestIndex - 1)
					slides.setCurrentSlide(slide.id)
				}}
			>
				{`+`}
			</button>
			<button
				style={{
					position: 'absolute',
					top: SLIDE_SIZE.h / 2,
					left: highestIndex * (SLIDE_SIZE.w + SLIDE_MARGIN) + (SLIDE_SIZE.w + SLIDE_MARGIN * 0.1),
					width: 40,
					height: 40,
					pointerEvents: 'all',
				}}
				onPointerDown={editor.markEventAsHandled}
				onClick={() => {
					const slide = slides.newSlide(highestIndex + 1)
					slides.setCurrentSlide(slide.id)
				}}
			>
				{`+`}
			</button>
		</>
	)
}

function SlideControls() {
	const slides = useSlides()
	const editor = useEditor()

	return (
		<>
			<button
				style={{
					pointerEvents: 'all',
					position: 'absolute',
					top: '50%',
					left: 0,
					width: 50,
					height: 50,
				}}
				onPointerDown={editor.markEventAsHandled}
				onClick={() => slides.prevSlide()}
			>
				{`<`}
			</button>
			<button
				style={{
					pointerEvents: 'all',
					position: 'absolute',
					top: '50%',
					right: 0,
					width: 50,
					height: 50,
				}}
				onPointerDown={editor.markEventAsHandled}
				onClick={() => slides.nextSlide()}
			>
				{`>`}
			</button>
		</>
	)
}

const components = {
	OnTheCanvas: Slides,
	InFrontOfTheCanvas: SlideControls,
}

/*
[1]
Whenever the current slide changes, the camera constraints are re-pointed at that slide's
bounds with `contain` behavior and `fit-max` zoom, so the user can't pan or zoom away from
it, then the camera is animated there. `force: true` is needed because the constraints
would otherwise block the move.

[2]
The slide list is app state (see SlidesManager.tsx); each slide is mirrored on the canvas as
a frame shape whose id is derived from the slide id with `createShapeId(slide.id)`. When
slides are inserted, existing frames are shifted and renumbered.

[3]
Two before-change side effects keep the frames in place: any change to a slide frame other
than renaming it is rejected, and slide frames are removed from the selection and hover
state so they can't be picked up by the select tool.

[4]
The buttons for inserting slides are rendered in `OnTheCanvas`, so they are positioned in
page space between the frames. `markEventAsHandled` on pointer down stops the canvas from
treating the press as the start of a drag.
*/

Slides are frame shapes laid out in a row. The current slide is tracked in a small reactive SlidesManager (an atom plus computed getters), and whenever it changes the example calls editor.setCameraOptions with bounds set to that frame and behavior: 'contain', then animates there with editor.zoomToBounds. The user can draw on the slide but can't pan or zoom away from it.

Side effects keep the slide frames from being moved, selected, or hovered, and buttons rendered in OnTheCanvas let you insert new slides between existing ones.

Compare with the "Slideshow (free camera)" example, which uses a custom slide shape and lets the camera roam.

Is this page helpful?
Prev
D3 geo map
Next
Slideshow (free camera)