Skip to content

React Integration

This guide shows a public-package-only React setup:

  • install prosekit directly
  • assemble the editor in your app
  • use a small local event extension instead of depending on our private in-house integration package

1. Install dependencies

Terminal window
pnpm add prosekit

Add the QTI packages you actually want to support on top of that. A typical setup includes:

Terminal window
pnpm add \
@qti-editor/core \
@qti-editor/interaction-choice \
@qti-editor/interaction-extended-text \
@qti-editor/interaction-inline-choice \
@qti-editor/interaction-match \
@qti-editor/interaction-order \
@qti-editor/interaction-select-point \
@qti-editor/interaction-text-entry

2. Add a small local event extension

Keep this helper in your own app. It emits document and selection changes onto an EventTarget.

qti-editor-events.ts
import { definePlugin, type Extension } from 'prosekit/core';
import { ListDOMSerializer } from 'prosekit/extensions/list';
import { Plugin, PluginKey } from 'prosekit/pm/state';
export interface QtiDocumentJson {
type: string;
content?: QtiNodeJson[];
}
export interface QtiNodeJson {
type: string;
attrs?: Record<string, unknown>;
content?: QtiNodeJson[];
text?: string;
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
}
export interface QtiContentChangeEventDetail {
json: QtiDocumentJson;
html: string;
timestamp: number;
}
export interface QtiSelectionChangeEventDetail {
from: number;
to: number;
empty: boolean;
timestamp: number;
}
export interface QtiEditorEventsOptions {
contentChangeEvent?: string;
selectionChangeEvent?: string;
emitContentChanges?: boolean;
emitSelectionChanges?: boolean;
eventTarget?: EventTarget;
}
const editorEventsPluginKey = new PluginKey('qti-editor-events');
export function qtiEditorEventsExtension(options: QtiEditorEventsOptions = {}): Extension {
const {
contentChangeEvent = 'qti:content:change',
selectionChangeEvent = 'qti:selection:change',
emitContentChanges = true,
emitSelectionChanges = true,
eventTarget = document,
} = options;
let lastDocJson: string | undefined;
return definePlugin(
() =>
new Plugin({
key: editorEventsPluginKey,
view(view) {
if (emitContentChanges) {
const json = view.state.doc.toJSON() as QtiDocumentJson;
lastDocJson = JSON.stringify(json);
const serializer = ListDOMSerializer.fromSchema(view.state.schema);
const fragment = serializer.serializeFragment(view.state.doc.content);
const div = document.createElement('div');
div.appendChild(fragment);
eventTarget.dispatchEvent(
new CustomEvent(contentChangeEvent, {
detail: { json, html: div.innerHTML, timestamp: Date.now() },
bubbles: true,
}),
);
}
return {
update(updatedView, prevState) {
if (emitContentChanges && !prevState.doc.eq(updatedView.state.doc)) {
const json = updatedView.state.doc.toJSON() as QtiDocumentJson;
const next = JSON.stringify(json);
if (next !== lastDocJson) {
lastDocJson = next;
const serializer = ListDOMSerializer.fromSchema(updatedView.state.schema);
const fragment = serializer.serializeFragment(updatedView.state.doc.content);
const div = document.createElement('div');
div.appendChild(fragment);
eventTarget.dispatchEvent(
new CustomEvent(contentChangeEvent, {
detail: { json, html: div.innerHTML, timestamp: Date.now() },
bubbles: true,
}),
);
}
}
if (emitSelectionChanges && !prevState.selection.eq(updatedView.state.selection)) {
eventTarget.dispatchEvent(
new CustomEvent(selectionChangeEvent, {
detail: {
from: updatedView.state.selection.from,
to: updatedView.state.selection.to,
empty: updatedView.state.selection.empty,
timestamp: Date.now(),
},
bubbles: true,
}),
);
}
},
};
},
}),
);
}

3. Create a useQtiEditor hook

useQtiEditor.ts
import { useEffect, useRef, useState } from 'react';
import { createEditor, union } from 'prosekit/core';
import { defineDoc, defineParagraph, defineText } from 'prosekit/extensions/doc';
import {
type QtiContentChangeEventDetail,
type QtiSelectionChangeEventDetail,
qtiEditorEventsExtension,
} from './qti-editor-events';
export function useQtiEditor() {
const mountRef = useRef<HTMLDivElement>(null);
const [content, setContent] = useState<QtiContentChangeEventDetail | null>(null);
const [selection, setSelection] = useState<QtiSelectionChangeEventDetail | null>(null);
useEffect(() => {
if (!mountRef.current) return;
const eventTarget = new EventTarget();
const editor = createEditor({
extension: union(
defineDoc(),
defineText(),
defineParagraph(),
qtiEditorEventsExtension({ eventTarget }),
),
});
const onContent = (event: Event) => {
setContent((event as CustomEvent<QtiContentChangeEventDetail>).detail);
};
const onSelection = (event: Event) => {
setSelection((event as CustomEvent<QtiSelectionChangeEventDetail>).detail);
};
eventTarget.addEventListener('qti:content:change', onContent);
eventTarget.addEventListener('qti:selection:change', onSelection);
editor.mount(mountRef.current);
return () => {
eventTarget.removeEventListener('qti:content:change', onContent);
eventTarget.removeEventListener('qti:selection:change', onSelection);
(editor as any).view?.destroy();
};
}, []);
return { mountRef, content, selection };
}

4. Use the hook in a component

QtiEditorHost.tsx
import { useQtiEditor } from './useQtiEditor';
export function QtiEditorHost() {
const { mountRef, content, selection } = useQtiEditor();
return (
<div>
<div ref={mountRef} style={{ minHeight: 200, border: '1px solid #ccc', padding: 16 }} />
{content && (
<details style={{ marginTop: 12 }}>
<summary>Saved content</summary>
<pre style={{ fontSize: 12 }}>{content.html}</pre>
</details>
)}
{selection && !selection.empty && (
<p style={{ fontSize: 12 }}>
Selection: {selection.from}-{selection.to}
</p>
)}
</div>
);
}

Notes

  • Keep the EventTarget private to a single editor instance.
  • Add QTI interaction node specs and commands through the public @qti-editor/interaction-* packages.
  • Use @qti-editor/core when you need descriptor metadata or QTI composition helpers.
  • editor.view?.destroy() is the correct cleanup call.