Angular Integration
This guide shows the Angular integration shape that matches the public QTI Editor surface:
- install the
@qti-editorQTI interaction and composition packages you need from npm - we offer some example Lit UI components, which you can copy into your app through the registry (more on that below)
- we recommend using ProseKit for ready-made UI components. If desired, install
prosekitdirectly from the vendor - when using ProseKit, keep a small local ProseKit helper layer in your Angular app for editor assembly and event wiring
We’ve provided a ready-made Angular integration example to serve as inspiration on how to wire things up.
1. Install packages
pnpm add prosekit \ @qti-components/theme \ @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-shared \ @qti-editor/interaction-text-entry \ @qti-editor/prosemirror \ @qti-editor/prosemirror-attributes \ @qti-editor/prosemirror-attributes-ui-prosekit \ litImportant
@qti-editor/uiis registry source, not the recommended Angular runtime import path.- The editor assembly layer is owned by your Angular app. Usage of our prosekit integration package is discouraged.
- The intended public model is:
prosekit+@qti-editor/interaction-*+@qti-editor/core+ copied registry UI.
2. Using the registry
Angular is not auto-detected by the shadcn CLI, so create the expected config files first.
{ "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": false, "tsx": false, "tailwind": { "config": "", "css": "src/styles.css", "baseColor": "neutral", "cssVariables": false, "prefix": "" }, "aliases": { "components": "@/components", "ui": "@/components", "lib": "@/lib", "utils": "@/lib/utils", "hooks": "@/hooks" }}{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }}export function cn(...classes: Array<string | false | null | undefined>) { return classes.filter(Boolean).join(' ');}Install the Lit UI through the registry
npx shadcn@latest add @prosekit/lit-example-toolbarnpx shadcn@latest add https://qti-editor.citolab.nl/r/lit-editor-slash-menu.jsonnpx shadcn@latest add https://qti-editor.citolab.nl/r/qti-composer.jsonnpx shadcn@latest add https://qti-editor.citolab.nl/r/qti-composer-metadata-form.jsonnpx shadcn@latest add https://qti-editor.citolab.nl/r/qti-attributes-panel.jsonnpx shadcn@latest add https://qti-editor.citolab.nl/r/qti-interaction-insert-menu.jsonnpx shadcn@latest add https://qti-editor.citolab.nl/r/qti-convert-menu.jsonThese files are boilerplate templates. Once copied into your Angular app, you own them.
Add Tailwind and DaisyUI, which are necessary to be able to use the registry components
pnpm add -D tailwindcss @tailwindcss/postcss postcsspnpm add @egoist/tailwindcss-icons @iconify-json/lucide daisyui{ "plugins": { "@tailwindcss/postcss": {} }}@import '@qti-components/theme/item.css';@import 'prosekit/basic/style.css';@import 'prosekit/basic/typography.css';@import "tailwindcss";
@variant dark (&:is(.dark *));
@source "./app/**/*.{html,ts}";@source "./components/**/*.{ts,js}";
@plugin "@egoist/tailwindcss-icons";@plugin "daisyui" { themes: light --default;}3. Using ProseKit? Add a small local editor helper layer
These will live inside your Angular app as app-owned glue code.
Local editor events
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, }), ); } }, }; }, }), );}Local interaction extension assembly
import { listInteractionDescriptors } from '@qti-editor/core/interactions/composer';import { defineBasicExtension } from 'prosekit/basic';import { defineKeymap, defineNodeSpec, union, type Extension } from 'prosekit/core';
import type { Command } from 'prosekit/pm/state';
export function defineQtiInteractionsExtension() { const descriptors = listInteractionDescriptors(); const seenSpecs = new Set<string>(); const nodeSpecExtensions: Extension[] = [];
for (const descriptor of descriptors) { for (const { name, spec } of descriptor.nodeSpecs) { if (seenSpecs.has(name)) continue; seenSpecs.add(name); nodeSpecExtensions.push(defineNodeSpec({ name, ...spec })); } }
const keymap: Record<string, Command> = {}; const enterCommands = descriptors .map(descriptor => descriptor.enterCommand) .filter((command): command is Command => command != null);
if (enterCommands.length > 0) { keymap['Enter'] = (state, dispatch, view) => enterCommands.some(command => command(state, dispatch, view)); }
for (const descriptor of descriptors) { if (descriptor.insertCommand && descriptor.keyboardShortcut) { keymap[descriptor.keyboardShortcut] = descriptor.insertCommand; } }
return union(...nodeSpecExtensions, defineKeymap(keymap));}
export function defineQtiExtension() { return union(defineBasicExtension(), defineQtiInteractionsExtension());}4. Mount the editor from an Angular host component
Angular owns the layout and lifecycle. The copied Lit elements receive the editor instance via refs.
import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectorRef, Component, ElementRef, NgZone, ViewChild, inject,} from '@angular/core';import { FormsModule } from '@angular/forms';import { createEditor, union, type Editor } from 'prosekit/core';import { blockSelectExtension, defineLocalStorageDocPersistenceExtension, defineSemanticPasteExtension, nodeAttrsSyncExtension, readPersistedStateFromLocalStorage,} from '@qti-editor/prosemirror';
import '../components/editor/ui/button/index.js';import '../components/editor/ui/image-upload-popover/index.js';import '../components/editor/ui/slash-menu/index.js';import '../components/editor/ui/toolbar/index.js';import '../components/blocks/composer/index';import '../components/blocks/composer-metadata-form/index';import '../components/blocks/attributes-panel/index';import '../components/blocks/interaction-insert-menu/index';import '../components/blocks/convert-menu/index';import { qtiEditorEventsExtension } from '../lib/qti-editor/events';import { defineQtiExtension } from '../lib/qti-editor/interactions';
const STORAGE_KEY = 'qti-editor-angular:prosemirror-doc:v1';
@Component({ selector: 'app-root', imports: [FormsModule], templateUrl: './app.html', styleUrl: './app.css', schemas: [CUSTOM_ELEMENTS_SCHEMA],})export class App { @ViewChild('toolbar', { static: true }) private readonly toolbarRef?: ElementRef<HTMLElement & { editor: Editor | null }>;
@ViewChild('slashMenu', { static: true }) private readonly slashMenuRef?: ElementRef<HTMLElement & { editor: Editor | null }>;
@ViewChild('insertMenu', { static: true }) private readonly insertMenuRef?: ElementRef<HTMLElement & { editor: Editor | null }>;
@ViewChild('convertMenu', { static: true }) private readonly convertMenuRef?: ElementRef<HTMLElement & { editor: Editor | null }>;
@ViewChild('attributesPanel', { static: true }) private readonly attributesPanelRef?: ElementRef<HTMLElement & { editor: Editor | null }>;
@ViewChild('composer', { static: true }) private readonly composerRef?: ElementRef<HTMLElement & { editor: Editor | null; identifier: string; title: string; lang: string; }>;
@ViewChild('mount') set mountRef(ref: ElementRef<HTMLDivElement> | undefined) { const el = ref?.nativeElement ?? null; if (!el) return; queueMicrotask(() => this.ngZone.runOutsideAngular(() => this.mountCurrentEditor(el))); }
protected identifier = 'ANGULAR_QTI_ITEM'; protected itemTitle = 'Angular QTI Item'; protected latestHtml = '';
private readonly cdr = inject(ChangeDetectorRef); private readonly ngZone = inject(NgZone); private readonly eventTarget = new EventTarget(); private readonly editor = createEditor({ extension: union( defineQtiExtension(), defineSemanticPasteExtension(), defineLocalStorageDocPersistenceExtension({ storageKey: STORAGE_KEY }), blockSelectExtension, nodeAttrsSyncExtension, qtiEditorEventsExtension({ eventTarget: this.eventTarget }), ), defaultContent: readPersistedStateFromLocalStorage(STORAGE_KEY).doc, });
constructor() { this.eventTarget.addEventListener('qti:content:change', event => { this.latestHtml = (event as CustomEvent<{ html: string }>).detail.html; this.cdr.detectChanges(); }); }
private mountCurrentEditor(el: HTMLElement): void { el.innerHTML = ''; this.editor.mount(el);
queueMicrotask(() => { this.toolbarRef!.nativeElement.editor = this.editor; this.slashMenuRef!.nativeElement.editor = this.editor; this.insertMenuRef!.nativeElement.editor = this.editor; this.convertMenuRef!.nativeElement.editor = this.editor; this.attributesPanelRef!.nativeElement.editor = this.editor; this.composerRef!.nativeElement.editor = this.editor; this.composerRef!.nativeElement.identifier = this.identifier; this.composerRef!.nativeElement.title = this.itemTitle; this.composerRef!.nativeElement.lang = 'en'; }); }}<section class="workspace"> <section class="editor-shell"> <div class="editor-toolbar"> <div class="editor-toolbar-row"> <div class="editor-qti-tools"> <qti-interaction-insert-menu #insertMenu></qti-interaction-insert-menu> <qti-convert-menu #convertMenu></qti-convert-menu> </div> <lit-editor-toolbar #toolbar></lit-editor-toolbar> </div> </div>
<div class="editor-viewport"> <div class="editor-stage"> <div #mount class="editor-mount"></div> <lit-editor-slash-menu #slashMenu class="editor-slash-menu"></lit-editor-slash-menu> </div> </div>
<qti-composer #composer></qti-composer> </section>
<aside class="inspector-shell"> <qti-composer-metadata-form [title]="itemTitle" [identifier]="identifier" ></qti-composer-metadata-form>
<qti-attributes-panel #attributesPanel></qti-attributes-panel> </aside></section>Angular-specific notes
- Add
CUSTOM_ELEMENTS_SCHEMAto the host component. - Import the copied registry files for side effects so the custom elements register.
- Assign
editor,editorView, andeventTargetvia refs, not HTML attributes. - Keep the helper layer local to your Angular app. It is intentionally app-owned glue.