Skip to content

Drawing overlay

A freehand layer the learner can switch on over any page, backed by Excalidraw. Strokes are pinned to the content underneath and saved locally per page. This guide is the current contract; the IndexedDB persistence design records the rationale and failure analysis behind it.

The contract

  • One drawing per page. The page key is the route pathname, narrowed by a scope when a container pages through content under a stable URL (see below).
  • A scene coordinate is a position inside the composed content column. Excalidraw's scrollX/scrollY are driven from the owning scroller on every scroll, resize, and Excalidraw-initiated pan, measured from the column's own top-left rather than the overlay's, and its zoom is held equal to the column's ContentScale factor. A stroke therefore stays over the words it was drawn on when the sidebar collapses, when the page is docked into a narrower pane, and when a split is undone.
  • The reading column does not reflow into its pane. ContentScale (src/components/ui/ContentScale.client.tsx) composes the column at a fixed reference width and scales it into the available width with CSS zoom, so line breaks are identical at every pane width. Text that rewrapped would move out from under strokes that a 2D canvas cannot follow. The reference is the text measure plus its gutters, not the whole column: the column's remaining width only ever holds the inspector aside, and composing against it would scale prose down for space nothing reads. Content wider than the measure still reflows between panes wider than the reference. Below MIN_SCALE the column stops shrinking and reflows again, and a viewport narrower than 768px renders unscaled.
  • Every visible pane places its own strokes. Each stack carries an overlay and each pane publishes its scroller through publishPaneScrollElement. The shell must not look those elements up in the document: tab buttons carry the same data-workspace-pane-id and come first.
  • Drawing mode is a mode. Outside it the layer is inert: no toolbars, no pointer events, just the strokes. Escape leaves the mode.
  • The chrome is ours, not Excalidraw's. Every Excalidraw island is hidden and replaced by DrawingToolbar, which lives in the page margin instead of on top of the text.
  • Nothing is uploaded. Drawings live in IndexedDB database tue-study-hub-drawings, version 1, in the pages object store keyed by pageKey.
  • Images are outside the contract. Pasting files or image elements is refused synchronously. The repository also filters unsupported image elements defensively, so it never persists an Excalidraw image without its separate binary file.

Enforced by src/components/DrawingOverlay.tsx, src/components/DrawingToolbar.client.tsx, src/course-os/services/drawings/, and the .drawing-overlay rules in src/app/globals.css.

Storage, loading, and saving

src/course-os/services/drawings/repository.ts is the only application-facing persistence boundary. It opens IndexedDB lazily, loads a page asynchronously, and populates an in-memory revisioned cache. DrawingOverlay does not mount a canvas until that load settles. Excalidraw's initialData remains a function, but it reads the latest cache entry through peekPageDrawing(pageKey) rather than performing storage I/O during render.

The function form is load-bearing. Erasing the final stroke unmounts the canvas; if a later mount replayed the original load snapshot, it would resurrect the erased scene and write it back. The cache is updated as soon as a scene changes, so it remains the truth while IndexedDB catches up.

Writes are scheduled per page with a 400 ms trailing debounce and a non-resetting 2 second maximum wait. Continuous drawing therefore cannot postpone persistence forever. Each page has its own promise chain; a failed write leaves that page dirty and the next flush retries it instead of poisoning future saves. The overlay requests an immediate flush:

  • before its page or repository data version changes;
  • when the document becomes hidden (visibilitychange);
  • on pagehide;
  • when the canvas disappears after its last stroke is erased; and
  • during overlay cleanup.

visibilitychange is the primary mobile lifecycle signal; pagehide is best-effort coverage on top of it.

Replacement barrier and backups

Import and reset replace the whole drawing collection, so normal canvas writes and cold reads cannot be allowed to cross that operation:

  1. The repository opens the database, flushes dirty cache entries, and snapshots the complete collection for rollback.
  2. Its private writeEpoch advances before replacement starts. Saves from an already-mounted canvas carry the previous epoch and are ignored before they can touch the cache.
  3. Volatile reads, timers, and cache state are cleared while the replacement runs. A cold read that meets the barrier waits and then reports itself stale.
  4. The public dataVersion advances only after the replacement succeeds or rollback finishes. The overlay subscribes to it and remounts under the settled collection; intermediate data is never published.

Replacements are serialized with each other and with exports. A failed replacement attempts to restore the snapshot before consumers are notified; a rollback persistence failure remains visible as a save error.

Settings exports backup schema v4. It contains progress, preferences, annotations, the complete drawing record, and a typed workspace snapshot covering theme, font, accent, sidebar state, drawing-toolbar position, and pen slots. Export waits for pending drawing writes and fails rather than presenting an incomplete backup when IndexedDB is unavailable.

Import compatibility is replacement by explicit instruction, preservation by omission:

  • v4 replaces drawings and workspace state with the snapshot in the backup;
  • v3 replaces drawings but preserves the current workspace state; and
  • v1 and v2 preserve both current drawings and workspace state.

For legacy imports, backup freshness is calculated from a complete v4 state assembled before the import: imported progress, preferences, and annotations plus whichever drawings and workspace the legacy schema preserves. This makes a successful import immediately up to date. If required preserved state cannot be captured or fingerprinted, the import still applies, no potentially false metadata is written, and Settings reports that backup status is unavailable.

Category resets follow the same ownership boundaries: drawings go through the replacement barrier; progress, review history, notes, and appearance/layout clear only their named records. Reset all attempts every category independently, preserves backup history and unrelated browser data, and reports named partial failures instead of claiming everything was cleared.

Legacy migration

The first successful repository open migrates the old tue-study-hub-drawings-v1 localStorage record. Valid page scenes are written into IndexedDB, unsupported images are dropped, and an existing IndexedDB page wins unless the legacy page has a strictly newer valid timestamp. The migration then reads committed pages back in a separate transaction before deleting the legacy key. Individually malformed pages are counted and dropped when the remaining valid pages migrate successfully. An unreadable envelope, an unresolved conflict, a write failure, or failed read-back leaves the legacy value in place so a later repository open can retry safely.

Failure states

Drawing remains usable in memory when IndexedDB cannot be opened. The app-shell action row reports Drawings kept for this session only; a failed save reports Drawings not saved. While drawing, the toolbar also shows Last change not saved with a Retry action. Settings export, import, and reset surface their own failures and do not announce success before their asynchronous drawing operation settles.

The toolbar

A bar docked to one of four edges of the drawing area. Dragging its grip snaps it to whichever edge the pointer is nearest, live rather than on release, and the choice is remembered under tue-study-hub-drawing-toolbar-v1. Left and right render it vertically, top and bottom horizontally. Right is the default: it stays clear of the centred content column, and it suits a left-handed user whose hand would otherwise cover a left-docked bar.

Tools are pen, highlighter, eraser, rectangle and arrow, plus one active-colour trigger, three pen slots, undo/redo and clear-page. Activating the colour control temporarily swaps those controls for an inline tray with Back, seven high-value presets (ink, white, red, yellow, green, blue and pink), and Custom colour. The tray follows the dock direction: horizontal at the top and bottom, vertical at the left and right. The trigger is a thin spectrum rim around the colour in use, seated in a 3px well of popover colour: the rim says the control is about colour, the chip says which one, and the well is what stops the pair reading as a rainbow donut with a hole in it. Presets are chips of the same size, and the one in use carries a centred tick whose colour flips with the swatch's lightness — a ring cut into a chip turns it into a donut and fights dark colours. Custom colour carries the same mark as the trigger, from the same component, so the two cannot drift apart. Choosing a preset returns to the tools immediately, and hands back to the tool you were last drawing with: Excalidraw reverts to selection once a shape is finished, and the eraser has no colour of its own, so the tool at the moment of picking is often not the one you want. Custom colour instead keeps the tray open — it opens an inward popover with a picker and accessible hex input whose changes apply live, so leaving on the first change would fight the drag; those changes are not persisted as preferences. Back or Escape restores the normal controls and focus to the colour trigger.

Pen slots

Each drawing mode owns three slots, and a slot holds a colour, a width and an opacity. Selecting a mode restores that mode's active slot in one scene update, so the pen stays black at 2px while the highlighter stays yellow at 6px and 40%, and neither disturbs the other. Clicking an unselected slot switches to it; clicking the one already in use opens an inward popover with width and opacity sliders. The colour trigger writes into the active slot of the mode on show, which is why colour survives a mode switch in the mode it was chosen for and nowhere else.

The eraser has no slots of its own, so it keeps showing the last drawing mode's. Clicking one there selects it and hands back to that mode: reaching for a pen while erasing means you are done erasing.

src/course-os/services/drawings/penSlots.ts owns the shape, the defaults and the storage, under tue-study-hub-drawing-slots-v1. It parses per mode: one malformed highlighter falls back to the default highlighter and leaves configured pens alone, and a mode added in a later version cannot discard the modes stored before it existed. Within a mode, one unusable slot resets that whole mode — a half-restored set is harder to make sense of than a reset one.

Three things about the toolbar are not obvious:

  • The highlighter is not an Excalidraw tool. It is freedraw at partial opacity, so Excalidraw cannot tell it from a pen. toMode infers it from opacity, which stopped being sufficient once a pen slot could be faint: a 70% pen would light up the highlighter button. So the toolbar records the mode it last chose, and resolveMode keeps it for as long as the tool it implies is still active. Any other tool means Excalidraw moved underneath us — its shortcuts, or the automatic revert to selection — and its answer is the only one there is.
  • There is no imperative undo. ExcalidrawImperativeAPI exposes only history.clear(), so undo and redo replay the Ctrl+Z / Ctrl+Shift+Z keydown at the Excalidraw container.
  • captureUpdate decides what is undoable. Clear-page passes IMMEDIATELY so it can be undone; every viewport sync and tool change passes NEVER, otherwise scrolling the page would fill the undo stack.

Most of Excalidraw's chrome has no public switch and is hidden via .layer-ui__wrapper. That selector is the one piece of this coupled to Excalidraw internals — check it after an upgrade.

Scoping a paged container

PagedBlockList shows one block at a time behind one URL, so the pathname alone would smear a drawing across every question in an exam paper. Such a container publishes the id of the item it is showing:

import { publishDrawingScope } from "@/course-os/services/drawings/scope";

useEffect(() => {
  if (!isPaged) return;
  publishDrawingScope(items[active].id);
}, [isPaged, items, active]);

useEffect(() => {
  if (!isPaged) return;
  return () => publishDrawingScope("");   // clear on unmount
}, [isPaged]);

The scope is a retained store rather than an event, because publishers mount deeper in the tree than the overlay and so always announce themselves first.

Do not key drawings on location.hash directly. Plain anchor links — the Contents navigator — also change it, and that must not swap a drawing out from under the learner.

Aligning a new layout

The overlay measures [data-content-column] inside the app shell's content area and offsets the scene by its left edge. Any centered page wrapper should carry that attribute, as src/components/ui/page-container.tsx and src/app/[courseId]/layout.tsx do. Without it the overlay falls back to its own left edge, which drifts from centered content when the sidebar moves.

Known limits

  • No server sync. IndexedDB is local to one browser profile. Use Settings export/import to move drawings to another device or preserve them before clearing site data.
  • No image elements. IndexedDB has capacity for larger scenes, but the persistence model still stores Excalidraw elements without its separate files map, so image pastes are refused.
  • Zoom is fixed at 1:1. Pinning to page scroll requires it, so there is no zoom control.
  • No text tool. Deliberate; add it to MODES in DrawingToolbar.client.tsx if that changes. A new mode also needs an entry in SLOTTED_MODES and a default slot set, or it has no slots.
  • Slot count is fixed at three. SLOTS_PER_MODE is validated on read, so changing it invalidates every stored set and resets everyone to the defaults.
  • No stroke styles. Excalidraw renders freedraw through perfect-freehand, which honours colour, width and opacity and ignores strokeStyle and roughness. Dashed or textured pens would mean patching its renderer, which is why slots carry those three properties and no more.

Verifying a change

  1. npm run check — covers IndexedDB transactions, migration, the cache and scheduler, repository replacement barriers, overlay lifecycle behavior, backup v4 and v1-v3 compatibility, and Settings actions.
  2. In the app: draw, scroll, and confirm the stroke keeps its position relative to the text.
  3. Reload — the stroke returns; navigate away — it does not follow.
  4. Collapse the sidebar and confirm the stroke stays on the same words.
  5. Drag the toolbar to each edge, reload, and confirm it returns to the last one.
  6. Set the pen to a thin red slot and the highlighter to a wide green one, switch between them, and confirm neither takes the other's colour or width. Reload and confirm both survive.
  7. Erase everything, toggle drawing off and on, and confirm nothing comes back.
  8. Paste an image and confirm Excalidraw refuses it.
  9. Export in Settings, clear the page, import the backup, and confirm the drawing returns.