Education Canvas

This example demonstrates how to create an educational application using tldraw. It features:

  • Split Layout: Question panel on the left, drawing canvas on the right
  • Fixed Camera: Canvas has constrained bounds to keep students focused on the drawing area
  • GCSE Math Question: A geometry problem suitable for GCSE-level mathematics
  • Grid Background: Coordinate grid to help with plotting points and shapes
  • Educational Styling: Clean, professional styling suitable for educational environments

Perfect for creating interactive math worksheets, geometry exercises, or any educational content that requires visual problem-solving.

import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Box, Editor, TLCameraOptions, TLComponents, Tldraw, track, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
import './education-canvas.css'

// Fixed camera options to prevent zooming/panning
const CAMERA_OPTIONS: Partial<TLCameraOptions> = {
	isLocked: false,
	constraints: {
		initialZoom: 'fit-max',
		baseZoom: 'fit-max',
		bounds: {
			x: 0,
			y: 0,
			w: 600,
			h: 600,
		},
		behavior: { x: 'contain', y: 'contain' },
		padding: { x: 100, y: 100 },
		origin: { x: 0.5, y: 0.5 },
	},
}

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

	useEffect(() => {
		if (!editor) return
		editor.run(() => {
			editor.zoomToBounds(new Box(0, 0, 600, 600), {
				inset: 150,
			})
			editor.setCameraOptions(CAMERA_OPTIONS)
			editor.setCamera(editor.getCamera(), {
				immediate: true,
			})
			// editor.updateInstanceState({
			// 	isGridMode: true,
			// })
		})
	}, [editor])

	return null
})

const TICKS = 8

const CartesianGrid = memo(function CartesianGrid() {
	return (
		<svg
			className="cartesian-grid"
			width="600"
			height="600"
			viewBox="0 0 600 600"
			stroke="#aaa"
			color="#aaa"
		>
			{Array.from({ length: TICKS * 2 + 1 }).map((_, i) => {
				const step = 600 / (TICKS * 2)
				const opacity = i === TICKS ? 1 : 0.16
				return (
					<g key={i + '_line'}>
						<line x1={0} y1={i * step} x2={600} y2={i * step} strokeWidth="1" opacity={opacity} />
						<line x1={i * step} y1={0} x2={i * step} y2={600} strokeWidth="1" opacity={opacity} />
					</g>
				)
			})}
			<g>
				{Array.from({ length: TICKS * 2 + 1 }).map((_, i) => {
					const index = i
					if (i - TICKS === 0) return null
					const y = 600 - index * (600 / (TICKS * 2))
					return (
						<g key={i + '_textx'}>
							<text
								key={index}
								x={312}
								y={y}
								dy="0.3em"
								fontFamily="Arial"
								textAnchor="start"
								letterSpacing=".25em"
								stroke="none"
								fill="#aaa"
								fontWeight="bold"
							>
								{-TICKS + index}
							</text>
							<line x1={295} y1={y} x2={305} y2={y} strokeWidth="2" />
						</g>
					)
				})}
				{Array.from({ length: TICKS * 2 + 1 }).map((_, i) => {
					const index = i
					if (i - TICKS === 0) return null
					const x = index * (600 / (TICKS * 2))
					return (
						<g key={i + '_texty'}>
							<text
								key={index}
								x={x}
								y={320}
								dy="0.3em"
								fontFamily="Arial"
								textAnchor="middle"
								stroke="none"
								fill="#aaa"
								fontWeight="bold"
							>
								{-TICKS + index}
							</text>
							<line x1={x} y1={295} x2={x} y2={305} strokeWidth="2" strokeLinecap="round" />
						</g>
					)
				})}
			</g>
		</svg>
	)
})

const components: TLComponents = {
	OnTheCanvas: CartesianGrid,
}

export default function EducationCanvasExample() {
	const [answers, setAnswers] = useState({
		partB: '',
		partC: '',
	})

	const handleAnswerChange = (part: keyof typeof answers, value: string) => {
		setAnswers((prev) => ({ ...prev, [part]: value }))
	}

	const rEditor = useRef<Editor | null>(null)
	const handleMount = useCallback((editor: Editor) => {
		rEditor.current = editor
	}, [])

	const handleSubmit = useCallback(async () => {
		// Normalize answers for comparison
		const normalizeAnswer = (answer: string) => {
			return answer.toLowerCase().replace(/[^a-z0-9(),.-]/g, '')
		}

		// Check Part B - Area (accept 8, 8 square units, 8 units², etc.)
		const normalizedB = normalizeAnswer(answers.partB)
		const isPartBCorrect =
			normalizedB.includes('8') &&
			(normalizedB.includes('square') || normalizedB.includes('unit') || normalizedB === '8')

		// Check Part C - Coordinates (accept (0,7), (0, 7), 0,7, etc.)
		const normalizedC = normalizeAnswer(answers.partC)
		const isPartCCorrect =
			normalizedC.includes('0') &&
			normalizedC.includes('7') &&
			(normalizedC.includes('(0,7)') ||
				normalizedC.includes('0,7') ||
				normalizedC.match(/0.*7/) ||
				normalizedC.match(/7.*0/))

		if (isPartBCorrect && isPartCCorrect) {
			alert('Good job! Both answers are correct!')
		} else if (isPartBCorrect || isPartCCorrect) {
			let message = 'Good progress! '
			if (isPartBCorrect) message += 'Part B is correct. '
			if (isPartCCorrect) message += 'Part C is correct. '
			if (!isPartBCorrect) message += 'Check your area calculation for Part B.'
			if (!isPartCCorrect) message += 'Check your coordinates for Part C.'
			alert(message)
		} else {
			alert('Please check your answers and try again.')
		}

		// Do something with the answers
		const editor = rEditor.current
		if (editor) {
			// For example, get the canvas content and the answers and send it to the server.
			// const result = {
			// 	answers: {
			// 		partA: await editor.toImage(editor.getCurrentPageShapes()),
			// 		partB: answers.partB,
			// 		partC: answers.partC,
			// 	},
			// }
			// console.log(result)
		}
	}, [answers])

	return (
		<div className="education-container">
			{/* Question Panel - Left Half */}
			<div className="question-panel">
				<div className="question-content">
					<h1 className="main-title">Mathematics - Geometry</h1>

					<div className="question-card">
						<h2 className="question-title">Question 1</h2>
						<p className="question-text">
							A triangle ABC has vertices at A(2, 3), B(6, 3), and C(4, 7).
						</p>

						<div className="question-part">
							<p className="question-text">
								<strong>Part A:</strong> Draw triangle ABC on the coordinate grid.
							</p>
						</div>

						<div className="question-part">
							<p className="question-text">
								<strong>Part B:</strong> Calculate the area of triangle ABC.
							</p>
							<div className="answer-input-group">
								<label className="answer-label">
									<strong>Answer:</strong>
								</label>
								<input
									type="text"
									className="answer-input"
									placeholder="Enter the area"
									value={answers.partB}
									onChange={(e) => handleAnswerChange('partB', e.target.value)}
								/>
							</div>
						</div>

						<div className="question-part">
							<p className="question-text">
								<strong>Part C:</strong> Find the coordinates of point D such that ABCD forms a
								parallelogram.
							</p>
							<div className="answer-input-group">
								<label className="answer-label">
									<strong>Answer:</strong>
								</label>
								<input
									type="text"
									className="answer-input"
									placeholder="Enter coordinates as (x, y)"
									value={answers.partC}
									onChange={(e) => handleAnswerChange('partC', e.target.value)}
								/>
							</div>
						</div>

						<button className="submit-button" onClick={handleSubmit}>
							Submit Answers
						</button>
					</div>

					<div className="instructions-card">
						<h3 className="instructions-title">Instructions:</h3>
						<ul className="instructions-list">
							<li>Use the drawing canvas on the right to sketch your solution</li>
							<li>
								You can use the draw tool <kbd>D</kbd> to draw points and the line tool <kbd>L</kbd>{' '}
								to draw lines
							</li>
							<li>
								Use the text tool <kbd>T</kbd> to label points and write calculations
							</li>
							<li>Show all your working clearly</li>
							<li>Enter your final answers in the answer boxes above</li>
						</ul>
					</div>
				</div>
			</div>

			{/* Canvas Panel - Right Half */}
			<div className="canvas-panel">
				<div className="canvas-container">
					<Tldraw
						options={{ maxPages: 1 }}
						persistenceKey="education-canvas"
						components={components}
						onMount={handleMount}
						overrides={{
							tools: (_editor, tools) => {
								// These are the tool ids that are allowed to be used in the education canvas...
								const allowedTools = ['select', 'hand', 'draw', 'eraser', 'line', 'text']
								// Tools are keyed by their id, so we can delete off all the tools that are not in the allowedTools array
								for (const key in tools) {
									if (!allowedTools.includes(key)) {
										delete tools[key]
									}
								}
								// Return the mutated tools
								return tools
							},
						}}
					>
						<CameraSetup />
					</Tldraw>
				</div>
			</div>
		</div>
	)
}
Is this page helpful?
Prev
Slideshow (free camera)
Next
Image annotator