@aime.ai/renderer-react
v1.10.0
Published
React component library for rendering AIME lesson packs with BlockSuite whiteboard support
Maintainers
Readme
@aime.ai/renderer-react
A composable React component library for rendering AIME lesson packs — structured educational content with slide-based presentation, a BlockSuite whiteboard, speaker notes, and full RTL / i18n support.
Table of Contents
- Installation
- Quick Start
- Core Concepts
- Editing Feature
- Components
- Contexts & Hooks
- Style Presets
- i18n / Labels
- Collaboration
- Canvas Persistence
- Types Reference
- Peer Dependencies
Installation
pnpm add @aime.ai/renderer-react
# or
npm install @aime.ai/renderer-reactImport the stylesheet once at your app root:
import "@aime.ai/renderer-react/style.css";Quick Start
import "@aime.ai/renderer-react/style.css";
import {
LessonProvider,
LessonWindow,
SlideNavigation,
PresentButton,
ExportButton,
CompleteLessonButton,
SlideSidebar,
SlideViewer,
FloatingNavigation,
NotesPanel,
BlockSuiteCanvas,
} from "@aime.ai/renderer-react";
import type { LessonPackOutput } from "@aime.ai/renderer-react";
function LessonPage({ pack }: { pack: LessonPackOutput }) {
return (
<LessonProvider pack={pack}>
<SlideNavigation>
<PresentButton />
<ExportButton onExport={() => downloadPdf(pack)} />
<CompleteLessonButton onComplete={() => router.back()} />
</SlideNavigation>
<LessonWindow>
<SlideSidebar onBack={() => router.back()} />
<SlideViewer />
<BlockSuiteCanvas />
</LessonWindow>
<FloatingNavigation onBack={() => router.back()} />
<NotesPanel />
</LessonProvider>
);
}Core Concepts
Composability — Every component is optional. Omit BlockSuiteCanvas if you don't need whiteboard annotations. Omit NotesPanel for a minimal viewer. Compose only what you need.
Shared state via context — LessonProvider wraps two React contexts:
LessonPackContext— holds the currentLessonPackOutput, the active slide index, and setters.PresentationContext— holds UI state: presentation mode, sidebar collapse, whiteboard mode, zoom level, text direction, and i18n labels.
All built-in components read from and write to these contexts automatically.
Editing Feature
The renderer ships a full inline-editing system. When editing is enabled, every slide component swaps its static display for live-editable primitives — teachers or content creators can update text, markdown content, lists, and images without leaving the lesson view.
Enabling Edit Mode
Pass editable and editCallbacks to LessonProvider:
import {
LessonProvider,
LessonWindow,
SlideNavigation,
EditButton,
EditView,
SlideViewer,
} from "@aime.ai/renderer-react";
import type { EditCallbacks } from "@aime.ai/renderer-react";
const editCallbacks: EditCallbacks = {
onSave: (updatedPack) => {
// Persist to your API whenever any field changes
api.patch(`/lesson-packs/${updatedPack.id}`, updatedPack);
},
onSelectImage: (resolve) => {
// Open your own image picker, then call resolve() with the URL
openMyImagePicker().then((url) => resolve(url));
},
onReorderSlides: (slides) => console.log("Slides reordered", slides),
onDeleteSlide: (index) => console.log("Slide deleted at", index),
};
function LessonEditor({ pack }) {
return (
<LessonProvider pack={pack} editCallbacks={editCallbacks}>
<SlideNavigation>
<EditButton />
</SlideNavigation>
<LessonWindow>
<EditView /> {/* Full-page editor — only renders when editable */}
<SlideViewer />{" "}
{/* Presentation viewer — hides when EditView is active */}
</LessonWindow>
</LessonProvider>
);
}Note:
editabledefaults tofalse. It can also be toggled at runtime viauseLessonPack().toggleEditable()or the built-inEditButtoncomponent.
EditView
A full-page slide management view that replaces the SlideViewer when editing is active. It renders null when editable is false, so it is safe to always include in the tree.
Features:
- Horizontal thumbnail grid — every slide is rendered as a live mini-preview.
- Drag-to-reorder — grab the grip handle on any card and drag it to a new position. Slide numbers are re-assigned automatically and
onReorderSlides+onSaveare called. - Delete — the trash icon on each card removes the slide;
onDeleteSlide+onSaveare called. - Inline editor — clicking a card opens a single-slide editing view where the active slide's primitive fields become editable. A breadcrumb header and Prev / Next arrows allow navigation between slides without returning to the grid.
<EditView />No props — reads all state from LessonPackContext.
EditButton
A toggle button that switches editable on and off via useLessonPack().toggleEditable(). Place it inside SlideNavigation.
<SlideNavigation>
<EditButton />
<PresentButton />
</SlideNavigation>No props.
EditCallbacks
All callbacks are optional. Pass only the ones you need.
interface EditCallbacks {
/**
* Called (debounced) whenever any slide field changes or slides are
* reordered / deleted. Receives the full updated pack.
*/
onSave?: (pack: LessonPackOutput) => void;
/**
* Invoked when the user clicks an image placeholder or "Change image".
* Implement your own file picker and call `resolve(url)` with the chosen
* URL, or `resolve(null)` to cancel.
*/
onSelectImage?: (resolve: (url: string | null) => void) => void;
/**
* Called after a drag-to-reorder operation completes.
* `slides` is the full new ordered array with updated `slide_number` values.
*/
onReorderSlides?: (slides: AnySlide[]) => void;
/**
* Called after a slide is deleted.
* `slideIndex` is the zero-based position of the deleted slide.
*/
onDeleteSlide?: (slideIndex: number) => void;
}useLessonPack — editing API
The following editing-specific members are available on useLessonPack():
const {
editable, // boolean — whether edit mode is active
toggleEditable, // () => void — toggle edit mode on/off
editCallbacks, // EditCallbacks — the callbacks passed to LessonProvider
updateSlideField, // <K extends keyof AnySlide>(key: K, value: AnySlide[K]) => void
} = useLessonPack();updateSlideField patches a single field on the currently active slide, merges it into the pack, and fires editCallbacks.onSave automatically.
// Update the title of the current slide
updateSlideField("title", "New Title");
// Update a slide-type-specific field (cast required for non-BaseSlide keys)
updateSlideField("content" as any, "# Updated content");Primitive editing components
All primitive components read editable from context automatically — they render their non-interactive version when editable is false, so you can render them unconditionally.
EditableText
Inline plain-text editor. Renders as a contentEditable element. Commits on blur or Enter; reverts on Escape.
import { EditableText } from "@aime.ai/renderer-react";
<EditableText
value={slide.title}
onChange={(v) => updateSlideField("title", v)}
className="font-black text-3xl text-zinc-900"
/>;| Prop | Type | Default | Description |
| ------------- | ------------------------------------------------ | ------------------ | -------------------------------- |
| value | string | — | Current text value. |
| onChange | (next: string) => void | — | Called on commit. |
| className | string | "" | Applied to the element. |
| as | "span" \| "p" \| "h1" \| "h2" \| "h3" \| "div" | "span" | Rendered tag. |
| placeholder | string | "Click to edit…" | Shown when empty. |
| multiline | boolean | false | Allow Shift+Enter for new lines. |
EditableMarkdown
Displays rendered markdown. In edit mode, clicking opens a raw-markdown <textarea> that auto-resizes. Commits on blur; reverts on Escape.
import { EditableMarkdown } from "@aime.ai/renderer-react";
<EditableMarkdown
value={slide.content}
onChange={(v) => updateSlideField("content" as any, v)}
className="text-zinc-800 text-base leading-relaxed"
/>;| Prop | Type | Default | Description |
| ----------- | ------------------------ | ------- | -------------------------------------------- |
| value | string | — | Raw markdown string. |
| onChange | (next: string) => void | — | Called on commit. |
| className | string | "" | Applied to both rendered and textarea views. |
EditableList
Edits a string[] array. Renders each item with an EditableText and shows Add / Remove controls. Supports a custom renderWrapper for per-item card styling.
import { EditableList } from "@aime.ai/renderer-react";
<EditableList
items={slide.objectives}
onChange={(v) => updateSlideField("objectives" as any, v)}
placeholder="Add an objective…"
renderWrapper={(child, i) => (
<div key={i} className="flex gap-3 p-4 bg-white border rounded-xl">
<span className="font-black text-blue-200 text-2xl">{i + 1}</span>
<div className="flex-1">{child}</div>
</div>
)}
/>;| Prop | Type | Default | Description |
| --------------- | ------------------------------------------------ | ------------------ | ------------------------------------------------------------ |
| items | string[] | — | Current array of strings. |
| onChange | (items: string[]) => void | — | Called with the entire updated array. |
| itemClassName | string | "" | className passed to each item's EditableText. |
| as | "span" \| "p" \| "div" | "span" | Tag for each item text. |
| placeholder | string | "Click to edit…" | Placeholder for empty items. |
| renderWrapper | (child: ReactNode, index: number) => ReactNode | — | Optional custom wrapper per item (e.g. icon + number badge). |
EditableImage
Displays an image with a hover overlay to change it in edit mode. When src is null in edit mode, renders an "Add image" placeholder button. Calls editCallbacks.onSelectImage to let the host app provide an image URL.
import { EditableImage } from "@aime.ai/renderer-react";
<EditableImage
src={slide.image_url}
alt="Slide image"
className="w-full h-48 rounded-2xl object-cover"
onChanged={(url) => updateSlideField("image_url", url)}
/>;| Prop | Type | Default | Description |
| ----------- | ------------------------------- | ------- | --------------------------------------------------------- |
| src | string \| null | — | Image URL. null shows the Add placeholder in edit mode. |
| alt | string | "" | Alt text. |
| className | string | "" | Applied to the wrapper. |
| onChanged | (url: string \| null) => void | — | Called with the new URL after the user picks an image. |
EditableImagerequireseditCallbacks.onSelectImageto be set onLessonProvider, otherwise the click does nothing.
Components
LessonProvider
Root provider and layout shell. Must wrap all other components from this library.
<LessonProvider
pack={lessonPack} // LessonPackOutput — pre-loads data into context
dir="rtl" // "ltr" | "rtl" — text direction (default: "ltr")
labels={myLabels} // LessonLabels — override button/label strings (i18n)
>
{/* your composed UI */}
</LessonProvider>| Prop | Type | Default | Description |
| -------- | ------------------ | ---------------- | -------------------------------------------------------------------------------------------- |
| pack | LessonPackOutput | — | Lesson data to render. Can be omitted and loaded later via useLessonPack().setCurrentPack. |
| dir | "ltr" \| "rtl" | "ltr" | Document text direction. Applied to the root <div>. |
| labels | LessonLabels | English defaults | UI string overrides for i18n. |
| style | SlideStyle | "default" | Visual style preset for all slides and chrome. "default" or "neobrutalist". |
LessonWindow
Flex row container for the main content area (sidebar + viewer). Place inside LessonProvider, after any top navigation.
<LessonWindow>
<SlideSidebar onBack={() => router.back()} />
<SlideViewer />
<BlockSuiteCanvas />
</LessonWindow>SlideNavigation
Top navigation bar. Accepts any combination of action buttons as children.
<SlideNavigation>
<PresentButton />
<ExportButton onExport={handleExport} />
<CompleteLessonButton onComplete={handleComplete} />
</SlideNavigation>PresentButton / ExitButton
Toggles presentation (full-screen) mode. Renders as Present when inactive and Exit when in presentation mode — labels are i18n-aware.
<PresentButton />ExportButton
Calls a user-supplied handler when clicked. Label is i18n-aware.
<ExportButton onExport={() => generateAndDownloadPdf(pack)} />| Prop | Type | Description |
| ---------- | ------------ | -------------------------------------------- |
| onExport | () => void | Required. Called when the button is pressed. |
CompleteLessonButton
Signals lesson completion. Label is i18n-aware.
<CompleteLessonButton onComplete={() => router.push("/dashboard")} />| Prop | Type | Description |
| ------------ | ------------ | -------------------------------------------- |
| onComplete | () => void | Required. Called when the button is pressed. |
SlideSidebar
Collapsible slide-strip sidebar. Shows a SlideThumbnail for every slide in the pack. Collapses automatically in presentation mode.
<SlideSidebar onBack={() => router.back()} />| Prop | Type | Description |
| -------- | ------------ | ----------------------------------------------------- |
| onBack | () => void | Required. Called when the Back button is pressed. |
SlideViewer
Renders the currently active slide, scaled to fit the available space. Handles zoom via PresentationContext. On mobile in portrait orientation, the slide auto-rotates 90deg to fill the screen.
<SlideViewer />No props — reads active slide and zoom from context.
SlideContainer
Lower-level wrapper used by SlideViewer. Use this if you need to render an individual slide without the full viewer infrastructure.
<SlideContainer slide={mySlide} scale={1} />| Prop | Type | Description |
| ------- | ---------- | ------------------------------------------------------- |
| slide | AnySlide | The slide data to render. |
| scale | number | Transform scale applied to the 1280 × 720 fixed canvas. |
SlideThumbnail
Renders a single slide at thumbnail size. Used inside SlideSidebar; also useful for custom slide pickers.
<SlideThumbnail slide={slide} index={0} isActive={currentIndex === 0} />| Prop | Type | Description |
| ---------- | ---------- | ---------------------------------------------------- |
| slide | AnySlide | Slide to render. |
| index | number | Zero-based position in the pack. |
| isActive | boolean | Whether this thumbnail is the active/selected slide. |
FloatingNavigation
Previous / Next arrow buttons that float over the viewer. On desktop, only visible in presentation mode. On mobile (< 768px), always visible as a translucent pill rotated 90deg on the right edge (matching the rotated slide orientation), with prev/next arrows and a close button.
<FloatingNavigation onBack={() => router.back()} />| Prop | Type | Description |
| -------- | ------------ | -------------------------------------------------------------------------------- |
| onBack | () => void | Optional. Called when the mobile close (X) button is pressed. Falls back to window.history.back(). |
NotesPanel
Slide-out panel showing teacher_notes for the current slide. Toggled via PresentationContext.toggleNotesPanel().
<NotesPanel />No props.
BlockSuiteCanvas
An edgeless BlockSuite whiteboard that overlays the slide content. Activates when isWhiteboardMode is true in PresentationContext.
<BlockSuiteCanvas
initialData={savedData}
onSave={(data) => persistCanvas(data)}
/>| Prop | Type | Description |
| ------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| initialData | Uint8Array | Optional. Restores a previously saved Yjs snapshot. When provided, user annotations are re-applied on top of the slides. |
| onSave | (data: Uint8Array) => void | Optional. Called (debounced 500 ms) whenever the canvas changes. Serialize to your backend or localStorage. Convert to number[] for JSON storage. |
See Canvas Persistence for a full example.
Contexts & Hooks
useLessonPack
Access and mutate the lesson pack state from any descendant of LessonProvider.
import { useLessonPack } from "@aime.ai/renderer-react";
function MyComponent() {
const {
currentPack, // LessonPackOutput | null
setCurrentPack, // (pack: LessonPackOutput | null) => void
currentSlideIndex, // number
setCurrentSlideIndex, // (index: number) => void
currentSlide, // AnySlide | null
} = useLessonPack();
}Use useLessonPackOptional() if the component might render outside a provider — returns null instead of throwing.
usePresentation
Access and mutate presentation UI state.
import { usePresentation } from "@aime.ai/renderer-react";
function MyComponent() {
const {
isPresentationMode, // boolean
isSidebarCollapsed, // boolean
isNotesPanelVisible, // boolean
isWhiteboardMode, // boolean
zoom, // number (50–200, default 90)
dir, // "ltr" | "rtl"
labels, // Required<LessonLabels>
// Setters
setIsPresentationMode,
setIsSidebarCollapsed,
setIsNotesPanelVisible,
setIsWhiteboardMode,
setZoom,
setDir,
// Convenience toggles
togglePresentationMode,
toggleSidebar,
toggleNotesPanel,
toggleWhiteboardMode,
zoomIn, // +10, max 200
zoomOut, // -10, min 50
resetZoom, // back to 100
} = usePresentation();
}Use usePresentationOptional() if the component might render outside a provider — returns undefined instead of throwing.
useIsMobile
Reactive hook that returns true when the viewport is below the mobile breakpoint (default: 768px). Useful for building responsive wrapper components.
import { useIsMobile } from "@aime.ai/renderer-react";
function MyComponent() {
const isMobile = useIsMobile();
return isMobile ? <MobileLayout /> : <DesktopLayout />;
}Accepts an optional breakpoint parameter (default: 768).
Style Presets
The renderer supports two visual style presets that control the look of all slides, chrome (sidebar, navigation, floating nav, edit views), and UI elements. Pass the style prop to LessonProvider:
<LessonProvider pack={pack} style="neobrutalist">
{/* all components inherit the preset */}
</LessonProvider>SlideStyle
type SlideStyle = "default" | "neobrutalist";"default"— Soft gradients, rounded corners, glass-morphism cards with backdrop blur, subtle shadows."neobrutalist"— Flat colors, sharp corners, hard black borders, solid offset shadows, warm off-white backgrounds.
useStylePreset() hook
Access the full preset object from any component inside LessonProvider:
import { useStylePreset } from "@aime.ai/renderer-react";
function MyCustomSlide() {
const s = useStylePreset();
return (
<div className={s.card.base}>
<p className={s.typography.heading}>Title</p>
<button className={s.button.primary}>Action</button>
</div>
);
}The StylePreset object provides semantic tokens for:
| Token | Description |
| --- | --- |
| s.backgrounds[key] | Background class per slide type (e.g. s.backgrounds.warmUp) |
| s.card.base | Main card style (bg, border, shadow, radius) |
| s.cardSmall.base | Secondary/smaller card style |
| s.button.primary | Filled action button (add your own bg color) |
| s.button.secondary | Outlined/ghost action button |
| s.button.ghost | Minimal hover-only button |
| s.badge.base | Badge/chip style |
| s.typography.* | Heading, body, label, muted text classes |
| s.accentBar | Decorative accent bar style |
| s.decorations.blob | Blob decoration class (or null in neobrutalist) |
| s.assessmentSlide.* | Tokens for assessment-type slides (title, body, card, chip, badge) |
| s.toggle.* | Toggle switch container/button styles |
| s.chrome.* | Sidebar, navigation, floating nav, slide viewer, edit view tokens |
| s.accents[color].* | Per-color accent sets (bg, border, text, fill, dot) |
Neobrutalist detection
Use const isNeo = !s.decorations.blob to conditionally apply preset-specific styles (e.g. square vs rounded progress dots, icon badge borders).
i18n / Labels
Every UI string in the renderer is injectable. Pass a labels prop to LessonProvider with only the keys you want to override — all others fall back to English defaults.
<LessonProvider
pack={pack}
dir="rtl"
labels={{
present: "عرض",
exit: "خروج",
exportLesson: "تصدير الدرس",
completeLesson: "إتمام الدرس",
back: "رجوع",
teacherNotes: "ملاحظات المعلم",
learningObjectives: "أهداف التعلم",
revealAnswer: "اكشف الإجابة",
}}
>
{/* ... */}
</LessonProvider>useLabels() hook
When building custom components that live inside LessonProvider, use useLabels() to consume the resolved label set:
import { useLabels } from "@aime.ai/renderer-react";
function MySlideWidget() {
const labels = useLabels(); // Returns Required<LessonLabels> — always fully populated
return <button>{labels.revealAnswer}</button>;
}useLabels() falls back to DEFAULT_LABELS (English) when called outside a PresentationProvider, making it safe to use in server-side or testing contexts.
LessonLabels interface
All fields are optional. Comments show the English default.
interface LessonLabels {
// Navigation / toolbar
present?: string; // "Present"
exit?: string; // "Exit"
exportLesson?: string; // "Export Lesson"
completeLesson?: string; // "Complete Lesson"
back?: string; // "Back"
edit?: string; // "Edit"
editDone?: string; // "Done"
editCancel?: string; // "Cancel"
editSlides?: string; // "Edit Slides"
allSlides?: string; // "All Slides"
dragToReorder?: string; // "Drag to reorder"
clickToEdit?: string; // "Click to edit…"
slides?: string; // "slides"
// Collaboration UI
pageMode?: string; // "Page Mode"
edgelessMode?: string; // "Edgeless Mode"
modeControlledByTeacher?: string; // "Mode controlled by teacher"
followingTeacher?: string; // "Following teacher"
followingTeacherTitle?: string; // "Following teacher — click to navigate freely"
freeNavigation?: string; // "Free navigation"
freeNavigationTitle?: string; // "Free navigation — click to follow teacher"
// SlideViewer
noSlideSelected?: string; // "No slide selected"
// NotesPanel
teacherNotes?: string; // "Teacher Notes"
noTeacherNotes?: string; // "No teacher notes added."
senNotes?: string; // "SEN Notes"
noSenNotes?: string; // "*No SEN notes added.*"
// Editing primitives
clickToEditMarkdown?: string; // "Click to edit markdown"
addImage?: string; // "Add image"
changeImage?: string; // "Change image"
deleteSlide?: string; // "Delete slide"
removeItem?: string; // "Remove item"
addItem?: string; // "Add item"
// Shared slide labels
of?: string; // "of"
question?: string; // "Question"
questions?: string; // "Questions"
answer?: string; // "Answer"
answers?: string; // "Answers"
answersOptional?: string; // "Answers (Optional)"
revealAnswer?: string; // "Reveal Answer"
problem?: string; // "Problem"
problems?: string; // "Problems"
task?: string; // "Task"
// Cover slide
inThisLesson?: string; // "In this lesson"
changeBackground?: string; // "Change Background"
addBackground?: string; // "Add Background"
// Teach slide
lessonContent?: string; // "Lesson Content"
keyTerms?: string; // "Key Terms"
// Hook slide
thinkAboutIt?: string; // "Think About It"
// Learning Objectives slide
learningObjectives?: string; // "Learning Objectives"
// Prior Knowledge slide
priorKnowledgeCheck?: string; // "Prior Knowledge Check"
// Worked Example slide
workedExample?: string; // "Worked Example"
checkHint?: string; // "Check / Hint"
stepByStepSolution?: string; // "Step-by-Step Solution"
addAStep?: string; // "Add a step…"
nextStep?: string; // "Next Step"
revealAll?: string; // "Reveal All"
finalAnswer?: string; // "Final Answer"
// Guided Practice slide
guidedPractice?: string; // "Guided Practice"
// Discussion slide
discussion?: string; // "Discussion"
discussionPrompt?: string; // "Discussion Prompt"
showTalkingPoints?: string; // "Show Talking Points"
nextPoint?: string; // "Next Point"
// Summary slide
lessonSummary?: string; // "Lesson Summary"
upNext?: string; // "Up Next"
// Exit Ticket slide
exitTicket?: string; // "Exit Ticket"
// Warm Up slide
warmUp?: string; // "Warm Up"
// Misconception slide
commonMisconceptions?: string; // "Common Misconceptions"
misconception?: string; // "Misconception"
correction?: string; // "Correction"
why?: string; // "Why?"
addMisconception?: string; // "Add Misconception"
removeMisconception?: string; // "Remove misconception"
showCorrection?: string; // "Show Correction"
showExplanation?: string; // "Show Explanation"
// Student Task slide
studentTask?: string; // "Student Task"
levelFoundation?: string; // "Foundation"
levelCore?: string; // "Core"
levelExtended?: string; // "Extended"
levelChallenge?: string; // "Challenge"
// Self Assessment slide
selfAssessment?: string; // "Self Assessment"
assessmentScale?: string; // "Assessment Scale"
objectivesToSelfAssess?: string; // "Objectives to Self-Assess"
// Concept Map slide
conceptMap?: string; // "Concept Map"
// Synthesis Task slide
synthesisTask?: string; // "Synthesis Task"
context?: string; // "Context"
modelAnswer?: string; // "Model Answer"
revealModelAnswer?: string; // "Reveal Model Answer"
// Mark Scheme slide
markScheme?: string; // "Mark Scheme"
mark?: string; // "mark"
marks?: string; // "marks"
partialCredit?: string; // "Partial Credit"
// Worked Solution slide
fullSolution?: string; // "Full Solution"
// Assessment slides
assessment?: string; // "Assessment"
minutes?: string; // "Minutes"
instructions?: string; // "Instructions"
allowedResources?: string; // "Allowed Resources"
// Generic slide
noAdditionalContent?: string; // "(No additional content)"
}Collaboration
CollaborationProvider wraps your lesson UI to enable real-time multi-user sessions over WebSocket (powered by y-websocket). Teachers control slide navigation and whiteboard mode for all following students; students can follow the teacher or switch to free navigation.
import {
LessonProvider,
LessonWindow,
SlideNavigation,
PresentButton,
FollowTeacherButton,
SlideSidebar,
SlideViewer,
BlockSuiteCanvas,
FloatingNavigation,
NotesPanel,
} from "@aime.ai/renderer-react";
import {
CollaborationProvider,
type CollaborationRole,
} from "@aime.ai/renderer-react";
function CollaborativeLessonPage({ pack, sessionId, role, userId, userName }) {
return (
<LessonProvider pack={pack}>
<CollaborationProvider
sessionId={sessionId}
role={role}
userId={userId}
userName={userName}
>
<SlideNavigation>
<FollowTeacherButton />
<PresentButton />
</SlideNavigation>
<LessonWindow>
<SlideSidebar onBack={() => router.back()} />
<SlideViewer />
<BlockSuiteCanvas />
</LessonWindow>
<FloatingNavigation />
<NotesPanel />
</CollaborationProvider>
</LessonProvider>
);
}Order matters —
CollaborationProvidermust be nested insideLessonProviderso it can readLessonPackContextandPresentationContextfor slide-sync and whiteboard-mode sync.
CollaborationProvider
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| sessionId | string | — | Shared session identifier — becomes the y-websocket room name. |
| role | CollaborationRole | "student" | "teacher", "student", or "observer". |
| userId | string | auto-generated | Stable user identifier. Re-use across page reloads to preserve identity. |
| userName | string | "Anonymous" | Display name shown in presence indicators. |
| userColor | string | auto-generated | CSS color for this user's presence dot. |
| roomWriteEnabled | boolean | false | Allow students to draw on the shared canvas. |
| serverUrl | string | derived from window.location | y-websocket server URL, e.g. wss://host/yjs. Defaults to ws(s)://CURRENT_HOST/yjs so it works through any tunnel automatically. |
| provider | WebsocketProvider | — | Inject a pre-existing WebsocketProvider (BYO transport). |
CollaborationRole
type CollaborationRole = "teacher" | "student" | "observer";teacher— controls slide navigation and whiteboard mode for all following students. Has write access to all canvas layers.student— follows the teacher by default. Can switch to free navigation viaFollowTeacherButton. Canvas write access is gated byroomWriteEnabled.observer— receives teacher navigation like a student but cannot write to any canvas layer.
FollowTeacherButton
A toggle button for students that switches between following teacher mode (teacher controls navigation) and free navigation mode (student navigates independently). Renders null when used outside a CollaborationProvider. Place it inside SlideNavigation.
<SlideNavigation>
<FollowTeacherButton />
<PresentButton />
</SlideNavigation>No props — reads and writes CollaborationContext directly. Labels are i18n-aware via LessonLabels (followingTeacher, followingTeacherTitle, freeNavigation, freeNavigationTitle, modeControlledByTeacher).
useCollaboration
import { useCollaboration } from "@aime.ai/renderer-react";
function TeacherControls() {
const {
sessionId, // string
role, // CollaborationRole
userId, // string
userName, // string
userColor, // string
awareness, // AwarenessInstance | null
peers, // PeerState[]
permissions, // CollaborationPermissions
setPermissions, // (partial: Partial<CollaborationPermissions>) => void
followTeacher, // boolean (students only)
setFollowTeacher, // (v: boolean) => void
getLayer, // (scope: string) => Y.Doc
canWrite, // (scope: string) => boolean
serverUrl, // string
} = useCollaboration();
}Use useCollaborationOptional() if the component might render outside a provider — returns null instead of throwing.
Peer awareness
Each connected peer publishes a PeerState object via y-websocket awareness. Read the live peer list from peers in the context:
interface PeerState {
userId: string;
userName: string;
userColor: string;
role: CollaborationRole;
slideIndex: number;
followTeacher: boolean;
isWhiteboardMode: boolean;
permissions?: CollaborationPermissions; // teacher only
}CollaborationPermissions
interface CollaborationPermissions {
/** Allow students to draw on the shared room canvas. */
roomWriteEnabled: boolean;
}Teachers can update permissions at runtime — student clients automatically adopt the new values via awareness:
const { setPermissions } = useCollaboration();
// Grant students canvas write access mid-session
setPermissions({ roomWriteEnabled: true });Canvas sync in collab mode
When a sessionId is present, BlockSuiteCanvas automatically creates a second y-websocket room ({sessionId}-canvas) for the edgeless Yjs doc. All strokes, shapes, and stickers replicate in real time across all peers.
The teacher's pan/zoom viewport is broadcast via awareness and applied on students who are following the teacher.
Students without roomWriteEnabled see a transparent read-only overlay on the canvas — they cannot interact with it but still see all remote changes live.
Canvas Persistence
BlockSuiteCanvas exposes a raw Yjs binary API so you can store annotations wherever you like.
import { useState, useCallback } from "react";
import {
LessonProvider,
LessonWindow,
SlideViewer,
BlockSuiteCanvas,
} from "@aime.ai/renderer-react";
import type { LessonPackOutput } from "@aime.ai/renderer-react";
function LessonPage({ pack }: { pack: LessonPackOutput }) {
// Restore from JSON field on the lesson pack
const initialData = pack.canvasData
? new Uint8Array(pack.canvasData)
: undefined;
const handleSave = useCallback(
(data: Uint8Array) => {
// Convert to number[] for JSON serialisation, then PATCH your API
saveLessonCanvas(pack.id, Array.from(data));
},
[pack.id],
);
return (
<LessonProvider pack={pack}>
<LessonWindow>
<SlideViewer />
<BlockSuiteCanvas initialData={initialData} onSave={handleSave} />
</LessonWindow>
</LessonProvider>
);
}LessonPackOutput includes a canvasData?: number[] field as a convenience slot — populate it from your API response and the component handles the rest.
If you need to serialize a doc manually (e.g. for an export flow), use the serializeDoc utility:
import { createSlideDoc, serializeDoc } from "@aime.ai/renderer-react";
const doc = createSlideDoc(pack.slides);
const bytes = serializeDoc(doc); // Uint8ArrayTypes Reference
LessonPackOutput
interface LessonPackOutput {
id: string;
lesson_intent_id: string;
version: number;
is_active: boolean;
status: LessonPackStatus; // "completed" | "generating" | "starting" | "failed"
lesson_type: LessonType;
title: string;
subject: string;
grade_level: string;
total_duration_minutes: number;
ai_rationale: string;
file_url: string | null;
created_at: string;
updated_at: string;
slides: AnySlide[];
resources: string[];
canvasData?: number[]; // Persisted Yjs state for the whiteboard
}LessonType enum
enum LessonType {
INTRODUCTION = "INTRODUCTION",
PRACTICE = "PRACTICE",
DEEP_DIVE = "DEEP_DIVE",
REVIEW = "REVIEW",
ASSESSMENT = "ASSESSMENT",
CONSOLIDATION = "CONSOLIDATION",
}Slide types
Every slide extends BaseSlide and has a discriminating slide_type field:
| slide_type | Type |
| --------------------------- | ----------------------------- |
| "TITLE" | TitleSlide |
| "LEARNING_OBJECTIVES" | LearningObjectivesSlide |
| "HOOK" | HookSlide |
| "PRIOR_KNOWLEDGE" | PriorKnowledgeSlide |
| "TEACH" | TeachSlide |
| "WORKED_EXAMPLE" | WorkedExampleSlide |
| "GUIDED_PRACTICE" | GuidedPracticeSlide |
| "STUDENT_TASK" | StudentTaskSlide |
| "WARM_UP" | WarmUpSlide |
| "DISCUSSION" | DiscussionSlide |
| "WORKED_SOLUTION" | WorkedSolutionSlide |
| "MISCONCEPTION" | MisconceptionSlide |
| "SELF_ASSESSMENT" | SelfAssessmentSlide |
| "CONCEPT_MAP" | ConceptMapSlide |
| "SYNTHESIS_TASK" | SynthesisTaskSlide |
| "SUMMARY" | SummarySlide |
| "EXIT_TICKET" | ExitTicketSlide |
| "ASSESSMENT_INSTRUCTIONS" | AssessmentInstructionsSlide |
| "ASSESSMENT_QUESTION" | AssessmentQuestionSlide |
| "MARK_SCHEME" | MarkSchemeSlide |
The union type AnySlide covers all of the above. Use a switch on slide_type for exhaustive handling.
BaseSlide
interface BaseSlide {
slide_number: number;
title: string;
teacher_notes: string;
duration_minutes: number;
teacher_only: boolean;
micro_objective_ids: number[];
sen_support: string | null;
image_url: string | null;
}Peer Dependencies
The following packages must be installed alongside this library:
{
"@blocksuite/block-std": "0.19.5",
"@blocksuite/blocks": "0.19.5",
"@blocksuite/icons": "2.1.75",
"@blocksuite/presets": "0.19.5",
"@blocksuite/store": "0.19.5",
"lit": "^3.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"yjs": "^13.0.0"
}Install them all at once:
pnpm add @blocksuite/[email protected] @blocksuite/[email protected] @blocksuite/[email protected] @blocksuite/[email protected] @blocksuite/[email protected] lit yjsCurrently, two official plugins are available:
React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see this documentation.
Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);You can also install eslint-plugin-react-x and eslint-plugin-react-dom for React-specific lint rules:
// eslint.config.js
import reactX from "eslint-plugin-react-x";
import reactDom from "eslint-plugin-react-dom";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs["recommended-typescript"],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);