Skip to content

Angular Integration

This guide shows the Angular integration shape that matches the public QTI Editor surface:

  • install the @qti-editor QTI 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 prosekit directly 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

Terminal window
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 \
lit

Important

  • @qti-editor/ui is 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.

components.json
{
"$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"
}
}
tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
src/lib/utils.ts
export function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ');
}

Install the Lit UI through the registry

Terminal window
npx shadcn@latest add @prosekit/lit-example-toolbar
npx shadcn@latest add https://qti-editor.citolab.nl/r/lit-editor-slash-menu.json
npx shadcn@latest add https://qti-editor.citolab.nl/r/qti-composer.json
npx shadcn@latest add https://qti-editor.citolab.nl/r/qti-composer-metadata-form.json
npx shadcn@latest add https://qti-editor.citolab.nl/r/qti-attributes-panel.json
npx shadcn@latest add https://qti-editor.citolab.nl/r/qti-interaction-insert-menu.json
npx shadcn@latest add https://qti-editor.citolab.nl/r/qti-convert-menu.json

These 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

Terminal window
pnpm add -D tailwindcss @tailwindcss/postcss postcss
pnpm add @egoist/tailwindcss-icons @iconify-json/lucide daisyui
.postcssrc.json
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
src/styles.css
@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

src/lib/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,
}),
);
}
},
};
},
}),
);
}

Local interaction extension assembly

src/lib/qti-editor/interactions.ts
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.

src/app/app.ts
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_SCHEMA to the host component.
  • Import the copied registry files for side effects so the custom elements register.
  • Assign editor, editorView, and eventTarget via refs, not HTML attributes.
  • Keep the helper layer local to your Angular app. It is intentionally app-owned glue.