React Integration
This guide shows a public-package-only React setup:
- install
prosekitdirectly - 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
pnpm add prosekitAdd the QTI packages you actually want to support on top of that. A typical setup includes:
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-entry2. Add a small local event extension
Keep this helper in your own app. It emits document and selection changes onto an EventTarget.
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
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
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
EventTargetprivate to a single editor instance. - Add QTI interaction node specs and commands through the public
@qti-editor/interaction-*packages. - Use
@qti-editor/corewhen you need descriptor metadata or QTI composition helpers. editor.view?.destroy()is the correct cleanup call.