Skip to content

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

Terminal window
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-extensions

File split

The boilerplate splits its editor across three files. Each file has a single concern:

FileOwns
schema.tsDocument topology — node specs, content/group rules
prosemirror-qti.tsDescriptors, plugins, attribute allowlist, item roundtrip
main.tsComposition 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 way
import { 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 use
import '@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:

FieldWhat it carries
tagNameqti-choice-interaction, qti-text-entry-interaction, … — used by the Insert menu
insertCommandProseMirror command that inserts the interaction at the selection
enterCommand / backspaceCommandOptional interaction-aware key commands
pluginFactoriesPlugin factories the interaction needs at runtime
decoratorPluginFactoriesOptional, opt-in authoring affordances (hover boundary, add/remove buttons, settings pill) — see Editor decorations below
attributePanelMetadataPer-node-type metadata: nodeTypeName, editableAttributes
nodeSpecsThe 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 })],
}),
});