Skip to content

Commit dba467e

Browse files
feat: add missing interactions to menus
1 parent ffc441d commit dba467e

11 files changed

Lines changed: 448 additions & 37 deletions

File tree

src/app/app.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
(fileNameChange)="fileName.set($event)"
88
(newFile)="onNewFile()"
99
(saveFile)="onSaveFile()"
10+
(importXml)="onImportXml()"
1011
(exportXml)="onExportXml()"
1112
(loadFile)="onLoadFile($event)"
1213
(deleteFile)="onDeleteFile($event)"

src/app/app.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ export class App {
9898
URL.revokeObjectURL(url);
9999
}
100100

101+
protected async onImportXml(): Promise<void> {
102+
try {
103+
await this.editorHost.importXml();
104+
this.currentFileId.set(null);
105+
this.fileName.set('imported-qti-item');
106+
} catch {
107+
this.errorMessage.set('Import failed: please choose a valid QTI XML file.');
108+
}
109+
}
110+
101111
protected onLoadFile(fileId: string): void {
102112
const file = this.savedFiles().find((f) => f.id === fileId);
103113
if (!file) return;

src/app/components/editor-host/editor-host.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ import {
4040
qtiEditorEventsExtension,
4141
type QtiContentChangeEventDetail,
4242
} from '../../../lib/qti-prosekit-integration/events';
43+
import { openXmlFilePicker } from '../../../lib/qti-prosekit-integration/import-xml';
4344
import { defineQtiExtension } from '../../../lib/qti-prosekit-integration/interactions-prosekit';
45+
import { defineSlashMenuGuardExtension } from '../../../lib/qti-prosekit-integration/slash-menu-guard';
46+
import { translateQti } from '@qti-editor/interaction-shared';
4447

4548
const STORAGE_KEY = 'qti-editor-angular:prosemirror-doc:v1';
4649

@@ -176,6 +179,30 @@ export class EditorHostComponent implements OnDestroy {
176179
}
177180
}
178181

182+
public async importXml(): Promise<void> {
183+
try {
184+
const result = await openXmlFilePicker({ schema: this.editor.schema });
185+
this.editor.setContent(result.json);
186+
187+
if (result.metadata && (result.metadata.identifier || result.metadata.title)) {
188+
const detail = {
189+
identifier: result.metadata.identifier || this.identifier(),
190+
title: result.metadata.title || this.itemTitle(),
191+
};
192+
193+
if (this.composerRef) {
194+
this.composerRef.nativeElement.identifier = detail.identifier;
195+
this.composerRef.nativeElement.title = detail.title;
196+
}
197+
198+
this.metadataChange.emit(detail);
199+
}
200+
} catch (error) {
201+
console.error('[QTI Editor] Failed to import XML:', error);
202+
throw error;
203+
}
204+
}
205+
179206
protected onMetadataChange(event: Event): void {
180207
const detail = (event as CustomEvent<{ title: string; identifier: string }>).detail;
181208
// Update composerRef immediately so the preview reflects the change before
@@ -191,11 +218,24 @@ export class EditorHostComponent implements OnDestroy {
191218
const extension = union(
192219
defineQtiExtension(),
193220
defineSemanticPasteExtension(),
221+
defineSlashMenuGuardExtension(),
194222
defineLocalStorageDocPersistenceExtension({ storageKey: STORAGE_KEY }),
195223
blockSelectExtension,
196224
nodeAttrsSyncExtension,
197225
qtiEditorEventsExtension({ eventTarget: this.eventTarget }),
198-
definePlaceholder({ placeholder: 'Typ / voor opdrachten', strategy: 'block' }),
226+
definePlaceholder({
227+
placeholder: (state) => {
228+
const { $anchor } = state.selection;
229+
230+
for (let depth = $anchor.depth; depth > 0; depth -= 1) {
231+
const placeholder = $anchor.node(depth).type.spec['placeholder'];
232+
if (placeholder) return placeholder;
233+
}
234+
235+
return translateQti('editor.placeholder', { target: document.body });
236+
},
237+
strategy: 'block',
238+
}),
199239
);
200240

201241
if (defaultContent) {

src/app/components/menu-bar/menu-bar.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
Save
2121
</button>
2222

23+
<button type="button" class="menu-btn" (click)="importXml.emit()">
24+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
25+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
26+
<polyline points="17 8 12 3 7 8"/>
27+
<line x1="12" y1="3" x2="12" y2="15"/>
28+
</svg>
29+
Import XML
30+
</button>
31+
2332
<button type="button" class="menu-btn" (click)="exportXml.emit()">
2433
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2534
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>

src/app/components/menu-bar/menu-bar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export class MenuBarComponent {
3232
readonly fileNameChange = output<string>();
3333
readonly newFile = output<void>();
3434
readonly saveFile = output<void>();
35+
readonly importXml = output<void>();
3536
readonly exportXml = output<void>();
3637
readonly loadFile = output<string>();
3738
readonly deleteFile = output<string>();

src/components/blocks/interaction-insert-menu/index.ts

Lines changed: 76 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { insertSelectPointInteraction } from '@qti-editor/interaction-select-poi
1313
import { insertInlineChoiceInteraction } from '@qti-editor/interaction-inline-choice';
1414
import { insertAssociateInteraction } from '@qti-editor/interaction-associate';
1515
import { insertHottextInteraction } from '@qti-editor/interaction-hottext';
16+
import { insertGap, insertGapMatchInteraction } from '../../../vendor/interaction-gap-match/dist/index.js';
17+
import { insertItemDivider } from '../../../vendor/qti-item-divider/dist/index.js';
1618

1719
import type { EditorView } from 'prosekit/pm/view';
1820

@@ -43,6 +45,20 @@ function canInsert(view: EditorView, nodeType: any): boolean {
4345
return false;
4446
}
4547

48+
function canInsertInline(view: EditorView, nodeType: any): boolean {
49+
const { $from } = view.state.selection;
50+
for (let depth = $from.depth; depth >= 0; depth -= 1) {
51+
const node = $from.node(depth);
52+
if (!node.type.inlineContent) continue;
53+
54+
const index = $from.index(depth);
55+
if (node.canReplaceWith(index, index, nodeType)) {
56+
return true;
57+
}
58+
}
59+
return false;
60+
}
61+
4662
function isSelectionInsideNodeType(view: EditorView, nodeType: any): boolean {
4763
const { $from } = view.state.selection;
4864
for (let depth = $from.depth; depth >= 0; depth -= 1) {
@@ -57,6 +73,18 @@ function getInteractionInsertItems(view: EditorView): InteractionInsertItem[] {
5773
const schema: any = view.state.schema;
5874
const items: InteractionInsertItem[] = [];
5975

76+
if (schema.nodes.qtiAssociateInteraction && schema.nodes.qtiSimpleAssociableChoice) {
77+
const nodeType = schema.nodes.qtiAssociateInteraction;
78+
items.push({
79+
label: translateQti('interactionInsert.associate', { target: view.dom }),
80+
canInsert: canInsert(view, nodeType),
81+
command: () => {
82+
insertAssociateInteraction(view.state, view.dispatch, view);
83+
view.focus();
84+
},
85+
});
86+
}
87+
6088
if (
6189
schema.nodes.qtiChoiceInteraction &&
6290
schema.nodes.qtiPrompt &&
@@ -75,27 +103,45 @@ function getInteractionInsertItems(view: EditorView): InteractionInsertItem[] {
75103
});
76104
}
77105

78-
if (schema.nodes.qtiTextEntryInteraction) {
79-
const nodeType = schema.nodes.qtiTextEntryInteraction;
106+
if (schema.nodes.qtiExtendedTextInteraction) {
107+
const nodeType = schema.nodes.qtiExtendedTextInteraction;
80108
items.push({
81-
label: translateQti('interactionInsert.textEntry', { target: view.dom }),
109+
label: translateQti('interactionInsert.extendedText', { target: view.dom }),
82110
canInsert: canInsert(view, nodeType),
83111
command: () => {
84-
const node = nodeType.createAndFill({ responseIdentifier: `RESPONSE_${crypto.randomUUID()}` });
85-
if (!node) return;
86-
view.dispatch(view.state.tr.replaceSelectionWith(node));
112+
insertExtendedTextInteraction(view.state, view.dispatch, view);
87113
view.focus();
88114
},
89115
});
90116
}
91117

92-
if (schema.nodes.qtiSelectPointInteraction) {
93-
const nodeType = schema.nodes.qtiSelectPointInteraction;
118+
if (schema.nodes.qtiGapMatchInteraction && schema.nodes.qtiGapText && schema.nodes.qtiGap) {
119+
const nodeType = schema.nodes.qtiGapMatchInteraction;
94120
items.push({
95-
label: translateQti('interactionInsert.selectPoint', { target: view.dom }),
121+
label: translateQti('interactionInsert.gapMatch', { target: view.dom }),
96122
canInsert: canInsert(view, nodeType),
97123
command: () => {
98-
insertSelectPointInteraction(view.state, view.dispatch, view);
124+
insertGapMatchInteraction(view.state, view.dispatch, view);
125+
view.focus();
126+
},
127+
});
128+
items.push({
129+
label: translateQti('interactionInsert.gap', { target: view.dom }),
130+
canInsert: insertGap(view.state),
131+
command: () => {
132+
insertGap(view.state, view.dispatch);
133+
view.focus();
134+
},
135+
});
136+
}
137+
138+
if (schema.nodes.qtiHottextInteraction && schema.nodes.qtiHottext) {
139+
const nodeType = schema.nodes.qtiHottextInteraction;
140+
items.push({
141+
label: translateQti('interactionInsert.hottext', { target: view.dom }),
142+
canInsert: canInsert(view, nodeType),
143+
command: () => {
144+
insertHottextInteraction(view.state, view.dispatch, view);
99145
view.focus();
100146
},
101147
});
@@ -105,7 +151,7 @@ function getInteractionInsertItems(view: EditorView): InteractionInsertItem[] {
105151
const nodeType = schema.nodes.qtiInlineChoiceInteraction;
106152
items.push({
107153
label: translateQti('interactionInsert.inlineChoice', { target: view.dom }),
108-
canInsert: !isSelectionInsideNodeType(view, nodeType) && canInsert(view, nodeType),
154+
canInsert: !isSelectionInsideNodeType(view, nodeType) && canInsertInline(view, nodeType),
109155
command: () => {
110156
insertInlineChoiceInteraction(view.state, view.dispatch, view);
111157
view.focus();
@@ -125,52 +171,51 @@ function getInteractionInsertItems(view: EditorView): InteractionInsertItem[] {
125171
});
126172
}
127173

128-
if (
129-
schema.nodes.qtiAssociateInteraction &&
130-
schema.nodes.qtiSimpleAssociableChoice
131-
) {
132-
const nodeType = schema.nodes.qtiAssociateInteraction;
174+
if (schema.nodes.qtiOrderInteraction && schema.nodes.qtiSimpleChoice) {
175+
const nodeType = schema.nodes.qtiOrderInteraction;
133176
items.push({
134-
label: translateQti('interactionInsert.associate', { target: view.dom }),
177+
label: translateQti('interactionInsert.order', { target: view.dom }),
135178
canInsert: canInsert(view, nodeType),
136179
command: () => {
137-
insertAssociateInteraction(view.state, view.dispatch, view);
180+
insertOrderInteraction(view.state, view.dispatch, view);
138181
view.focus();
139182
},
140183
});
141184
}
142185

143-
if (schema.nodes.qtiOrderInteraction && schema.nodes.qtiSimpleChoice) {
144-
const nodeType = schema.nodes.qtiOrderInteraction;
186+
if (schema.nodes.qtiSelectPointInteraction) {
187+
const nodeType = schema.nodes.qtiSelectPointInteraction;
145188
items.push({
146-
label: translateQti('interactionInsert.order', { target: view.dom }),
189+
label: translateQti('interactionInsert.selectPoint', { target: view.dom }),
147190
canInsert: canInsert(view, nodeType),
148191
command: () => {
149-
insertOrderInteraction(view.state, view.dispatch, view);
192+
insertSelectPointInteraction(view.state, view.dispatch, view);
150193
view.focus();
151194
},
152195
});
153196
}
154197

155-
if (schema.nodes.qtiHottextInteraction && schema.nodes.qtiHottext) {
156-
const nodeType = schema.nodes.qtiHottextInteraction;
198+
if (schema.nodes.qtiTextEntryInteraction) {
199+
const nodeType = schema.nodes.qtiTextEntryInteraction;
157200
items.push({
158-
label: translateQti('interactionInsert.hottext', { target: view.dom }),
159-
canInsert: canInsert(view, nodeType),
201+
label: translateQti('interactionInsert.textEntry', { target: view.dom }),
202+
canInsert: canInsertInline(view, nodeType),
160203
command: () => {
161-
insertHottextInteraction(view.state, view.dispatch, view);
204+
const node = nodeType.createAndFill({ responseIdentifier: `RESPONSE_${crypto.randomUUID()}` });
205+
if (!node) return;
206+
view.dispatch(view.state.tr.replaceSelectionWith(node));
162207
view.focus();
163208
},
164209
});
165210
}
166211

167-
if (schema.nodes.qtiExtendedTextInteraction) {
168-
const nodeType = schema.nodes.qtiExtendedTextInteraction;
212+
if (schema.nodes.qtiItemDivider) {
213+
const nodeType = schema.nodes.qtiItemDivider;
169214
items.push({
170-
label: translateQti('interactionInsert.extendedText', { target: view.dom }),
215+
label: translateQti('interactionInsert.itemDivider', { target: view.dom }),
171216
canInsert: canInsert(view, nodeType),
172217
command: () => {
173-
insertExtendedTextInteraction(view.state, view.dispatch, view);
218+
insertItemDivider(view.state, view.dispatch);
174219
view.focus();
175220
},
176221
});

src/components/editor/ui/slash-menu/slash-menu.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { insertHottextInteraction } from '@qti-editor/interaction-hottext';
1919
import { insertMatchInteraction } from '@qti-editor/interaction-match';
2020
import { insertOrderInteraction } from '@qti-editor/interaction-order';
2121
import { insertSelectPointInteraction } from '@qti-editor/interaction-select-point';
22+
import { insertGapMatchInteraction } from '../../../../vendor/interaction-gap-match/dist/index.js';
23+
import { insertItemDivider } from '../../../../vendor/qti-item-divider/dist/index.js';
2224

2325
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
2426
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
@@ -53,6 +55,10 @@ class SlashMenuElement extends LitElement {
5355
editor: {
5456
attribute: false
5557
},
58+
disabled: {
59+
type: Boolean,
60+
reflect: true,
61+
},
5662
};
5763

5864
removeUpdateExtension;
@@ -166,7 +172,7 @@ class SlashMenuElement extends LitElement {
166172

167173
return html`<prosekit-autocomplete-popover
168174
.editor=${editor}
169-
.regex=${regex}
175+
.regex=${this.disabled ? null : regex}
170176
class="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
171177
>
172178
<prosekit-autocomplete-list .editor=${editor}>
@@ -214,6 +220,26 @@ class SlashMenuElement extends LitElement {
214220
></lit-editor-slash-menu-item>
215221
`
216222
: ''}
223+
${schema?.nodes.qtiGapMatchInteraction
224+
? html`
225+
<lit-editor-slash-menu-item
226+
class="contents"
227+
label="Gap match interaction"
228+
?disabled=${!canInsert(view, schema.nodes.qtiGapMatchInteraction)}
229+
@select=${() => this.runViewCommand((currentView) => insertGapMatchInteraction(currentView.state, currentView.dispatch, currentView))}
230+
></lit-editor-slash-menu-item>
231+
`
232+
: ''}
233+
${schema?.nodes.qtiItemDivider
234+
? html`
235+
<lit-editor-slash-menu-item
236+
class="contents"
237+
label="Item divider"
238+
?disabled=${!canInsert(view, schema.nodes.qtiItemDivider)}
239+
@select=${() => this.runViewCommand((currentView) => insertItemDivider(currentView.state, currentView.dispatch))}
240+
></lit-editor-slash-menu-item>
241+
`
242+
: ''}
217243
${schema?.nodes.qtiMatchInteraction
218244
? html`
219245
<lit-editor-slash-menu-item

0 commit comments

Comments
 (0)