@instructure/platform-block-content-editor
v3.4.0
Published
Host-agnostic block content editor for Canvas activities — the blocks, the read-path viewer, and the write-path editor, all on native InstUI.
Maintainers
Keywords
Readme
@instructure/platform-block-content-editor
Host-agnostic block content editor for the Canvas activity builder: the blocks, their schemas, the read path that renders a saved document, and the write path that authors one — all on native InstUI, with every Canvas coupling behind an injectable seam. Owned by foundation.
This package ships building blocks for rebuilding the editor, not a finished app. There is one
convenient composition (BlockContentEditor), but everything it does is reproducible from the
exposed primitives, so a host can arrange the pieces however it needs (a tray or the preview in a
modal, a custom toolbar, its own layout).
Deep design notes and invariants live in
CLAUDE.md; the block-authoring conventions in thebce-block-authoringskill; architecture decisions in the repo'sdocs/adr/. See Documentation below.
Install
pnpm add @instructure/platform-block-content-editorInstUI, React, and zod are peer dependencies — the host provides its single shared copy of
each (see the peerDependencies in package.json).
The package is ESM-only ("type": "module", one . export plus ./locales/en.json). Styles are
emotion runtime-injected — there is no ./styles import. There is also no PlatformUiProvider:
BCE is host-agnostic through its own set of seam contexts (see Seams),
not a single umbrella provider. A minimal host can render Viewer / BlockContentEditor with just
React, InstUI theming, and a composed registry.
The registry (both paths need it)
There is deliberately no default registry: which blocks a document may contain is the host's
decision. Compose one from the shipped block factories — the keys are the wire format stored in
documents (Header → Banner, QuoteBlock → Quote, …):
import { defineRegistry, createBannerBlock, createTextBlock } from '@instructure/platform-block-content-editor'
const registry = defineRegistry({
Header: createBannerBlock({ label: t('Banner'), translations: {/* … */} }),
Text: createTextBlock({ label: t('Rich content editor'), translations: {/* … */} }),
// …the blocks this host offers, plus any host-owned blocks
})Compose with defineRegistry(…); never annotate : BlockRegistry (it widens the type and silently
collapses every derived type — see CLAUDE.md).
Read path — render a saved document
import { Viewer } from '@instructure/platform-block-content-editor'
<Viewer templateLayout={layout} templateData={data} componentRegistry={registry} />Viewer emits no wrapper and takes no theme; host-specific typography belongs on the host's
container. Unresolved blocks are reported via onUnresolvedBlock, never dropped or mutated.
Write path — author a document
The host owns the state engine (so it owns save, undo/redo, dirty-tracking, and DB↔package
conversion). Call useBlockEditorState and hand the result to the editor:
import { useBlockEditorState, BlockContentEditor } from '@instructure/platform-block-content-editor'
function Builder() {
const engine = useBlockEditorState({ initialData, initialLayout, registry, onDirty })
return (
<BlockContentEditor
engine={engine}
registry={registry}
tools={[widgetTray, aiAssistantTool]} // host-tool slot (optional)
/>
)
}BlockContentEditor composes the tool-panel sidebar over the drag-and-drop canvas: it builds the
registry-driven insertion trays, hoists a single DnD context so blocks can be dragged from a
tray onto the canvas, coordinates canvas↔panel selection focus, and routes a selected block to its
settings form. It is authoring-only — see Preview below.
Or compose the primitives yourself
Every arrangement above is reproducible. Wrap your own layout in one GridDndProvider and drop a
bare DesignerCanvas and the trays inside it (so tray drag reaches the canvas), or use the
standalone DesignerArea (which self-wraps its own provider) when you don't need a shared context:
import { GridDndProvider, DesignerCanvas, ComponentsTray, DesignerNavProvider } from '@instructure/platform-block-content-editor'
<GridDndProvider {...dndProps}>
<DesignerNavProvider><ComponentsTray registry={registry} onAddComponent={engine.addComponent} /></DesignerNavProvider>
<DesignerCanvas canvasRef={canvasRef} {...canvasProps} />
</GridDndProvider>Also public: DesignerSideBar (the tool-panel chrome), ComponentEditorForm (the settings form),
useDesignerNav (the sidebar's view-navigation hook), useDesignerSelectionFocus, and the
block-level AI (BlockAIMenu, useGenerateAltText).
Preview — the host presents it
PreviewArea is a framed student preview over the read-path Viewer, sized from a required,
controlled viewport prop ('desktop' | 'mobile'). It renders no switch of its own — the host
builds the Desktop/Mobile toggle and drives viewport from its own state. The package ships
no modal: how the preview is presented (modal, drawer, fullscreen, inline) is the host's choice.
import { PreviewArea, PREVIEW_VIEWPORTS, type PreviewViewport } from '@instructure/platform-block-content-editor'
const [viewport, setViewport] = useState<PreviewViewport>(PREVIEW_VIEWPORTS.DESKTOP)
<Modal open={previewing} onDismiss={…}>
<div role="group" aria-label="Preview viewport">
<Button onClick={() => setViewport(PREVIEW_VIEWPORTS.DESKTOP)}>Desktop</Button>
<Button onClick={() => setViewport(PREVIEW_VIEWPORTS.MOBILE)}>Mobile</Button>
</div>
<PreviewArea
templateLayout={engine.templateLayout}
templateData={engine.templateData}
componentRegistry={registry}
viewport={viewport}
/>
</Modal>Seams — everything Canvas-specific is injected
Blocks and the editor read only from context; the host provides the implementations:
| Context | What the host injects |
| --- | --- |
| BrandColorContext | the brand color (hex/rgb()), or undefined |
| BlockTranslationsProvider | resolved strings per block + designer chrome (bundled English is the fallback) |
| RichTextRendererContext | a renderer for RCE-authored HTML (read path); unset → sanitized fallback |
| RichTextEditorContext | the RCE editor component (write path); unset → plain TextArea |
| AIActionContext | the AI executor; presence enables the block-level AI, unset → hidden |
| BlockEditorApiProvider | host data hooks — image/video/widget URLs and the settings-form file upload/delete |
Each is host-agnostic: the host closes over Canvas course/plugin/file config inside its own hook/component; the package only ever passes opaque ids and reads back resolved values.
Wiring it up
The canonical nesting a host provides (the order proven by the consumer/index.tsx reintegration
fixture) — supply only the seams you use; each has a safe default:
<BrandColorContext.Provider value={brandColor}>
<DesignerModeContext.Provider value={false}>
<BlockEditorApiProvider value={hostDataHooks}> {/* image/video/widget + upload hooks */}
<RichTextRendererContext.Provider value={renderHtml}>
<RichTextEditorContext.Provider value={HostRceEditor}>
<AIActionContext.Provider value={executeAiToolUse}> {/* presence enables block-level AI */}
<BlockTranslationsProvider value={resolvedStrings}>
{/* <Viewer />, <BlockContentEditor />, <PreviewArea /> … */}
</BlockTranslationsProvider>
</AIActionContext.Provider>
</RichTextEditorContext.Provider>
</RichTextRendererContext.Provider>
</BlockEditorApiProvider>
</DesignerModeContext.Provider>
</BrandColorContext.Provider>Reintegration gate
The public surface is the contract a host compiles against. pnpm check:consumer builds dist/ and
type-checks the consumer/ fixture against it, catching any exported inferred type that can't be
named across the exports map (TS2742) — which the package's own type-check never sees. Add
newly-exported symbols to consumer/index.tsx.
Development
pnpm --filter @instructure/platform-block-content-editor test # vitest run
pnpm --filter @instructure/platform-block-content-editor type-check # tsc -p tsconfig.check.json
pnpm --filter @instructure/platform-block-content-editor build # vite build (+ .d.ts emit)
pnpm --filter @instructure/platform-block-content-editor check:consumer # the reintegration gate
pnpm storybook # 21 stories: every block + the editorCross-package types resolve through built dist/, so run pnpm build once after a clone/pull
before type-check/test (see the gotchas in CLAUDE.md). Storybook needs Node ≥ 22.12.
Documentation
- This README — how to consume the package.
CLAUDE.md— what is true insrc/right now: the read/write paths, the seams, the non-negotiable invariants, and the build gotchas.bce-block-authoringskill (.claude/skills/) — the conventions for creating or modifying a block (styling, accessibility, the schema toolkit, per-block translations), loaded on demand.docs/adr/(repo root) — architecture decision records; append-only.- In-file comments — reasoning local to one file, especially the block barrels and
src/schema/.
