TypeScript Integration
This guide mirrors the qti-example-editor boilerplate — a minimal pure-ProseMirror QTI roundtrip editor. Use it as the reference shape for your own integration.
Install
pnpm add prosemirror-state prosemirror-view prosemirror-model prosemirror-commands \ prosemirror-keymap prosemirror-history prosemirror-dropcursor prosemirror-gapcursor \ prosemirror-menu prosemirror-schema-basic prosemirror-schema-list prosemirror-tables \ @citolab/prose-qti @citolab/prose-extensionsFile split
The boilerplate splits its editor across three files. Each file has a single concern:
| File | Owns |
|---|---|
schema.ts | Document topology — node specs, content/group rules |
prosemirror-qti.ts | Descriptors, plugins, attribute allowlist, item roundtrip |
main.ts | Composition root — view, state, plugin stack, menu bar |
1. Schema
schema.ts builds the document topology from createQtiSchema() in @citolab/prose-qti/schema, rather than restating every interaction’s node spec by hand. A hand-restated content or group silently wins over the package’s own, so a schema change upstream (a loosened content expression, say) goes unnoticed until an editor built on the stale copy rejects a document the package considers valid — see QTI Base Schema.
createQtiSchema() composes the QTI basics (qtiBasicNodes/qtiBasicMarks), lists, tables with richtext cell content, and every registered interaction, with doc requiring identifier/title attributes:
import { Schema } from 'prosemirror-model';import { createQtiSchema } from '@citolab/prose-qti/schema';
const qtiSchema = createQtiSchema();
export const appSchema = new Schema({ marks: qtiSchema.spec.marks, nodes: qtiSchema.spec.nodes,});Pass { include: ['qti-choice-interaction', /* … */] } to restrict the schema to specific interactions by tag name, or { extraNodes: { /* … */ } } to merge in node specs the package doesn’t own — extras are merged last, so they win.
2. QTI integration
prosemirror-qti.ts owns descriptors, plugins, the attribute allowlist, and the roundtrip helpers.
import { chainCommands } from 'prosemirror-commands';import { keymap } from 'prosemirror-keymap';import { choiceInteractionDescriptor } from '@citolab/prose-qti/components/choice';import { extendedTextInteractionDescriptor } from '@citolab/prose-qti/components/extended-text';import { textEntryInteractionDescriptor } from '@citolab/prose-qti/components/text-entry';// …import the rest the same wayimport { exportItemXml, importItemFromUrl } from '@citolab/prose-qti/item-roundtrip';import { listInteractionDecoratorPluginFactories } from '@citolab/prose-qti/core/interactions/composer';
// Side-effect imports register the Lit custom elements used by the node views.import '@citolab/prose-qti/components/choice/register.js';import '@citolab/prose-qti/components/text-entry/register.js';// …one per interaction, plus the shared elements you useimport '@citolab/prose-qti/components/shared/components/qti-prompt/register.js';import '@citolab/prose-qti/components/shared/components/qti-simple-choice/register.js';
import type { InteractionDescriptor } from '@citolab/prose-qti/interfaces';import type { Node as ProseMirrorNode, Schema } from 'prosemirror-model';import type { Plugin } from 'prosemirror-state';
export const descriptors: InteractionDescriptor[] = [ choiceInteractionDescriptor, extendedTextInteractionDescriptor, textEntryInteractionDescriptor, // …];
// Per-node-type allowlist of editable attributes (used by the attributes panel).export const editableAttrs = Object.fromEntries( descriptors.flatMap(descriptor => Object.values(descriptor.attributePanelMetadata ?? {}).map(metadata => [ metadata.nodeTypeName, metadata.editableAttributes ?? [], ]) ));
// Enter/Backspace chain across every interaction. Each descriptor's command// returns false when it doesn't apply, so unhandled keys fall through to the// list-split and base keymaps that come later in the plugin stack.const enterCommand = chainCommands(...descriptors.flatMap(d => d.enterCommand ?? []));const backspaceCommand = chainCommands(...descriptors.flatMap(d => d.backspaceCommand ?? []));
export const qtiPlugins: Plugin[] = [ keymap({ Enter: enterCommand, Backspace: backspaceCommand }), ...descriptors.flatMap(d => d.pluginFactories?.map(factory => factory()) ?? []), // Opt-in authoring affordances (hover boundary, add/remove buttons, settings pill) — currently // shipped by the choice descriptor. Drop this line for a read-only or player host. ...listInteractionDecoratorPluginFactories().map(factory => factory()),];
export function importQtiItem(href: string, schema: Schema): Promise<ProseMirrorNode> { return importItemFromUrl(href, schema);}
export function exportQtiItem(doc: ProseMirrorNode, schema: Schema): string { return exportItemXml(doc, schema);}Each InteractionDescriptor exposes:
| Field | What it carries |
|---|---|
tagName | qti-choice-interaction, qti-text-entry-interaction, … — used by the Insert menu |
insertCommand | ProseMirror command that inserts the interaction at the selection |
enterCommand / backspaceCommand | Optional interaction-aware key commands |
pluginFactories | Plugin factories the interaction needs at runtime |
decoratorPluginFactories | Optional, opt-in authoring affordances (hover boundary, add/remove buttons, settings pill) — see Editor decorations below |
attributePanelMetadata | Per-node-type metadata: nodeTypeName, editableAttributes |
nodeSpecs | The ProseMirror node specs the interaction contributes (also re-exported as qti*InteractionNodeSpec) |
3. Composition root
main.ts assembles the editor and mounts it. The plugin order matters: qtiPlugins come before the list/table keymaps so the QTI Enter/Backspace overrides win, and keymap(baseKeymap) sits at the end so unhandled keys fall through.
import { EditorState } from 'prosemirror-state';import { EditorView } from 'prosemirror-view';import { keymap } from 'prosemirror-keymap';import { baseKeymap } from 'prosemirror-commands';import { history, undo, redo } from 'prosemirror-history';import { dropCursor } from 'prosemirror-dropcursor';import { gapCursor } from 'prosemirror-gapcursor';import { blockSelectPlugin, nodeAttrsSyncPlugin } from '@citolab/prose-extensions/prosemirror';
import { appSchema as schema } from './schema.js';import { qtiPlugins, importQtiItem, exportQtiItem } from './prosemirror-qti.js';
const editorPlugins = [ history(), keymap({ 'Mod-z': undo, 'Mod-y': redo, 'Shift-Mod-z': redo }), ...qtiPlugins, keymap(baseKeymap), dropCursor(), gapCursor(), blockSelectPlugin, // selecting whole interaction nodes nodeAttrsSyncPlugin, // applies inline attr edits dispatched as DOM events];
const doc = await importQtiItem('/items/item-1.xml', schema);
const view = new EditorView(document.querySelector('#editor')!, { state: EditorState.create({ doc, plugins: editorPlugins }), dispatchTransaction(tr) { view.updateState(view.state.apply(tr)); },});
document.querySelector('#export-btn')!.addEventListener('click', () => { const xml = exportQtiItem(view.state.doc, schema); const url = URL.createObjectURL(new Blob([xml], { type: 'application/xml' })); Object.assign(document.createElement('a'), { href: url, download: 'item.xml' }).click(); URL.revokeObjectURL(url);});Editor decorations (opt-in)
qtiPlugins above already folds in listInteractionDecoratorPluginFactories(), so the boilerplate’s
choice interaction gets its hover boundary, add/remove buttons and settings pill for free. Pair it
with the matching stylesheet:
@import '@citolab/prose-qti/decorations.css';The settings pill dispatches a bubbling, composed QTI_OPEN_NODE_SETTINGS_EVENT rather than opening
anything itself — wire a listener on the editor container to select the node and scope your own
attributes panel to it:
import { QTI_OPEN_NODE_SETTINGS_EVENT } from '@citolab/prose-qti/components/shared';import { NodeSelection } from 'prosemirror-state';import type { QtiOpenNodeSettingsDetail } from '@citolab/prose-qti/components/shared';
container.addEventListener(QTI_OPEN_NODE_SETTINGS_EVENT, event => { const { pos } = (event as CustomEvent<QtiOpenNodeSettingsDetail>).detail; if (!view.state.doc.nodeAt(pos)) return; view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos))); view.focus();});See Choice Interaction for what the
decorator provides and how to opt out of it (drop decoratorPluginFactories from qtiPlugins and
the stylesheet import) for a read-only or player build.
Toolbar
Use prosemirror-menu for an Insert dropdown built from the descriptors:
import { menuBar, MenuItem, Dropdown } from 'prosemirror-menu';import { descriptors } from './prosemirror-qti.js';
const insertInteractionDropdown = new Dropdown( descriptors.map(descriptor => new MenuItem({ run: descriptor.insertCommand!, enable: state => descriptor.insertCommand!(state), label: descriptor.tagName, title: `Insert ${descriptor.tagName} interaction`, })), { label: 'Insert' });
// Add `menuBar({ content: [[insertInteractionDropdown], …] })` to your plugin stack.Attributes panel
The boilerplate ships its own local attributes-panel ProseMirror plugin — a small file that renders the selected node’s attribute chain into a side panel and applies edits via transactions. See attributes-panel-plugin.ts in the boilerplate; it consumes the editableAttrs allowlist exported from prosemirror-qti.ts.
import { attributesPanelPlugin } from './components/attributes-panel-plugin.js';import { editableAttrs } from './prosemirror-qti.js';
const view = new EditorView(host, { state: EditorState.create({ doc, plugins: [...editorPlugins, attributesPanelPlugin(panelEl, { editableAttrs })], }),});