Skip to content

TypeScript Integration

This guide mirrors the qti-prosemirror-item 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 is one literal — every interaction’s qti*InteractionNodeSpec is spread in alongside core prose nodes. content and group are restated inline so the whole topology is visible in one file.

import { Schema } from 'prosemirror-model';
import { nodes as basicNodes, marks } from 'prosemirror-schema-basic';
import { orderedList, bulletList, listItem } from 'prosemirror-schema-list';
import { tableNodes } from 'prosemirror-tables';
import { qtiChoiceInteractionNodeSpec } from '@citolab/prose-qti/components/choice';
import { qtiTextEntryInteractionNodeSpec } from '@citolab/prose-qti/components/text-entry';
import {
qtiPromptNodeSpec,
qtiPromptParagraphNodeSpec,
qtiSimpleChoiceNodeSpec,
qtiSimpleChoiceParagraphNodeSpec,
} from '@citolab/prose-qti/components/shared';
export const appSchema = new Schema({
marks,
nodes: {
doc: { content: 'block+', attrs: { identifier: {}, title: {} } },
paragraph: { ...basicNodes.paragraph, content: 'inline*', group: 'block richtext' },
text: basicNodes.text,
ordered_list: { ...orderedList, content: 'list_item+', group: 'block richtext' },
bullet_list: { ...bulletList, content: 'list_item+', group: 'block richtext' },
list_item: { ...listItem, content: 'paragraph (paragraph | bullet_list | ordered_list)*' },
...tableNodes({ tableGroup: 'block richtext', cellContent: 'richtext+', cellAttributes: {} }),
// QTI shared building blocks
qtiPrompt: { ...qtiPromptNodeSpec, content: 'qtiPromptParagraph' },
qtiPromptParagraph: { ...qtiPromptParagraphNodeSpec, content: 'text*', group: 'block' },
qtiSimpleChoice: { ...qtiSimpleChoiceNodeSpec, content: 'qtiSimpleChoiceParagraph' },
qtiSimpleChoiceParagraph: { ...qtiSimpleChoiceParagraphNodeSpec, content: 'text*', group: 'block' },
// QTI interactions
qtiChoiceInteraction: { ...qtiChoiceInteractionNodeSpec },
qtiTextEntryInteraction: { ...qtiTextEntryInteractionNodeSpec },
},
});

Add the other interactions (extended-text, associate, gap-match, hottext, inline-choice, match, order, select-point, rubric-block) the same way — import their qti*InteractionNodeSpec and spread into the literal.

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';
// 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()) ?? []),
];
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
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);
});

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 })],
}),
});