QTI Base Schema
@citolab/prose-qti/schema provides qtiBasicNodes and qtiBasicMarks — a drop-in replacement for importing prosemirror-schema-basic directly when you assemble a raw ProseMirror schema for a QTI host. It is also re-exported from the package root (@citolab/prose-qti).
When to use this
Use this package when you build your ProseMirror Schema by hand (see the TypeScript Integration guide) rather than assembling extensions through ProseKit. If you already use ProseKit, defineBasicExtension() from @citolab/prose-extensions/prosekit covers the same ground.
Why not prosemirror-schema-basic directly
- Image dimensions.
qtiBasicNodes.imagepreserveswidthandheightattributes on parse and serialize. Plainprosemirror-schema-basicdrops them, so QTI XML image dimensions are lost on import and re-export. - A single import path. Nodes and marks come from one module, so a host is not assembling its baseline from two sources and hoping they agree.
What it provides
qtiBasicNodes—prosemirror-schema-basic’s node specs with theimagenode’sparseDOM/toDOMreplaced to round-tripwidth/height, plus aqtiLayoutDivnode for the QTI layout wrappers.qtiBasicMarks—prosemirror-schema-basic’s marks, unchanged, re-exported for a single consistent import path.qtiLayoutDivLockPlugin— an optional ProseMirror plugin that keepsqtiLayoutDivwrappers from being added or removed by editing. Not included automatically; add it to your plugin list.qtiPasteRescuePlugin— an optional ProseMirror plugin that fixes up a multi-block paste landing in a slot that only holds one block. Not included automatically; add it to your plugin list.
Nothing is removed from prosemirror-schema-basic — one node is overridden and one is added.
Layout wrappers (qtiLayoutDiv)
qtiBasicNodes.qtiLayoutDiv models the <div class="qti-layout-row"> / <div class="qti-layout-colN"> presentation wrappers that QTI items use for grid layout. They are author-written in the source XML — no editor offers a command to create one — so a schema that omits this node silently drops every wrapper on import.
Import and export preserve the wrapper’s class attribute exactly; editing the content inside a column, or changing a wrapper’s own class, both work normally.
Because nothing can author a new wrapper, an editor that lets a selection spanning one delete it leaves an author with structure they have no command to rebuild. qtiLayoutDivLockPlugin closes that gap:
import { qtiLayoutDivLockPlugin } from '@citolab/prose-qti/schema';
const plugins = [...otherPlugins, qtiLayoutDivLockPlugin];It rejects any transaction that would change how many qtiLayoutDiv nodes the document contains, and leaves everything else — including re-classing a wrapper — untouched. It is a separate export rather than automatic, so a host that genuinely wants editable grids simply omits it from its plugin list.
Paste rescue (qtiPasteRescuePlugin)
Some interaction slots hold exactly one block — a choice’s text, a prompt, an associable choice’s content. Pasting two or more blocks into one of those has nowhere to go: ProseMirror’s own fitting logic closes out of the slot and reopens it for the remainder, which splits one choice (or one prompt) into two.
qtiPasteRescuePlugin rewrites a paste like that so it fits instead of splitting: a slot that
repeats (qtiSimpleChoice+) gets one new sibling per pasted block, each with its own identifier; a
slot that does not repeat (qtiPrompt?) gets every pasted block joined into the one slot it has.
Which interactions and slots this applies to is not hardcoded — it asks the schema itself whether the
target already accepts more than one block, so it keeps working as interactions are added or their
content models change.
import { qtiPasteRescuePlugin } from '@citolab/prose-qti/schema';
const plugins = [...otherPlugins, qtiPasteRescuePlugin];Order relative to a semantic-paste plugin (see ProseMirror Plugins) does not matter — that one runs on the clipboard HTML, this one runs on the already-parsed slice, and ProseMirror always runs the two stages in that order.
Typical usage
import { Schema } from 'prosemirror-model';import { qtiBasicNodes, qtiBasicMarks } from '@citolab/prose-qti';import { qtiChoiceInteractionNodeSpec } from '@citolab/prose-qti/components/choice';
export const appSchema = new Schema({ marks: qtiBasicMarks, nodes: { doc: { content: 'block+', attrs: { identifier: {}, title: {} } }, paragraph: { ...qtiBasicNodes.paragraph, content: 'inline*', group: 'block richtext' }, text: qtiBasicNodes.text, image: qtiBasicNodes.image,
// QTI interactions qtiChoiceInteraction: { ...qtiChoiceInteractionNodeSpec }, },});Only the node names you list in the nodes object end up in the schema — qtiBasicNodes is a plain lookup object, not a schema fragment applied automatically. Restate content/group for each node as your document topology requires, the same as for any other node spec in this repo (see the TypeScript Integration guide).
Notes
blockquoteis included, along withhorizontal_ruleandcode_block. An earlier note here claimed QTI item bodies do not modelblockquote; that was wrong. The QTI 3 implementation guide’s own examples place<blockquote>inside<qti-item-body>(§3.2.3, §3.2.8), and<hr>,<pre>and<code>are permitted too. Dropping a node the format allows does not narrow a schema so much as silently discard an author’s markup, since ProseMirror’s parser omits what it cannot place rather than reporting it.qtiBasicMarksisprosemirror-schema-basic’smarksobject as-is — mark identity is unaffected, sostrong/em/code/linkare unchanged.