@qti-editor/interaction-associate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/qti-associate-interaction/qti-associate-interaction.commands.d.ts +22 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.commands.d.ts.map +1 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.commands.js +71 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.compose.d.ts +3 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.compose.d.ts.map +1 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.compose.js +56 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.d.ts +62 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.d.ts.map +1 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.js +302 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.schema.d.ts +3 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.schema.d.ts.map +1 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.schema.js +52 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.styles.d.ts +4 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.styles.d.ts.map +1 -0
- package/dist/components/qti-associate-interaction/qti-associate-interaction.styles.js +152 -0
- package/dist/composer/handler.d.ts +3 -0
- package/dist/composer/handler.d.ts.map +1 -0
- package/dist/composer/handler.js +6 -0
- package/dist/composer/metadata.d.ts +21 -0
- package/dist/composer/metadata.d.ts.map +1 -0
- package/dist/composer/metadata.js +38 -0
- package/dist/descriptor.d.ts +31 -0
- package/dist/descriptor.d.ts.map +1 -0
- package/dist/descriptor.js +22 -0
- package/dist/extensions/correct-response.d.ts +13 -0
- package/dist/extensions/correct-response.d.ts.map +1 -0
- package/dist/extensions/correct-response.js +62 -0
- package/dist/extensions/index.d.ts +2 -0
- package/dist/extensions/index.d.ts.map +1 -0
- package/dist/extensions/index.js +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/package.json +45 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QTI Associate Interaction Commands
|
|
3
|
+
*
|
|
4
|
+
* ProseMirror commands for inserting and manipulating associate interactions.
|
|
5
|
+
*/
|
|
6
|
+
import type { Command } from 'prosemirror-state';
|
|
7
|
+
/**
|
|
8
|
+
* Command to insert an associate interaction at the current selection
|
|
9
|
+
*/
|
|
10
|
+
export declare const insertAssociateInteraction: Command;
|
|
11
|
+
/**
|
|
12
|
+
* Handles Enter inside qti-simple-associable-choice paragraphs by inserting a new empty
|
|
13
|
+
* sibling qti-simple-associable-choice directly after the current one.
|
|
14
|
+
*/
|
|
15
|
+
export declare const insertAssociableChoiceOnEnter: Command;
|
|
16
|
+
/**
|
|
17
|
+
* Enter command chain for associate interactions.
|
|
18
|
+
* 1) Insert new associable choice when inside qti-associate-interaction.
|
|
19
|
+
* 2) Fallback to regular block split behavior elsewhere.
|
|
20
|
+
*/
|
|
21
|
+
export declare const qtiAssociateEnterCommand: Command;
|
|
22
|
+
//# sourceMappingURL=qti-associate-interaction.commands.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qti-associate-interaction.commands.d.ts","sourceRoot":"","sources":["../../../src/components/qti-associate-interaction/qti-associate-interaction.commands.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAOH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAGjD;;GAEG;AACH,eAAO,MAAM,0BAA0B,EAAE,OA4CxC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,6BAA6B,EAAE,OA0B3C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,OAAkE,CAAC"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QTI Associate Interaction Commands
|
|
3
|
+
*
|
|
4
|
+
* ProseMirror commands for inserting and manipulating associate interactions.
|
|
5
|
+
*/
|
|
6
|
+
import { chainCommands, splitBlock } from 'prosemirror-commands';
|
|
7
|
+
import { createInsertSiblingOnEnterCommand } from '@qti-editor/interaction-shared/commands/enter.js';
|
|
8
|
+
import { createInsertBlockInteractionCommand } from '@qti-editor/interaction-shared/commands/insert.js';
|
|
9
|
+
import { translateQti } from '@qti-editor/interaction-shared';
|
|
10
|
+
/**
|
|
11
|
+
* Command to insert an associate interaction at the current selection
|
|
12
|
+
*/
|
|
13
|
+
export const insertAssociateInteraction = (state, dispatch, view) => {
|
|
14
|
+
const target = view?.dom ?? null;
|
|
15
|
+
return createInsertBlockInteractionCommand({
|
|
16
|
+
createNode: currentState => {
|
|
17
|
+
const { schema } = currentState;
|
|
18
|
+
const promptType = schema.nodes.qtiPrompt;
|
|
19
|
+
const promptParagraphType = schema.nodes.qtiPromptParagraph;
|
|
20
|
+
const associableChoiceType = schema.nodes.qtiSimpleAssociableChoice;
|
|
21
|
+
const associableChoiceParagraphType = schema.nodes.qtiSimpleAssociableChoiceParagraph;
|
|
22
|
+
const interactionType = schema.nodes.qtiAssociateInteraction;
|
|
23
|
+
if (!promptType || !promptParagraphType || !associableChoiceType || !associableChoiceParagraphType || !interactionType) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const responseIdentifier = `RESPONSE_${crypto.randomUUID()}`;
|
|
27
|
+
const prompt = promptType.create(null, promptParagraphType.create(null, schema.text(translateQti('prompt.associate.default', { target }))));
|
|
28
|
+
const choices = [
|
|
29
|
+
associableChoiceType.create({ identifier: `CHOICE_${crypto.randomUUID()}`, matchMax: 2 }, associableChoiceParagraphType.create(null, schema.text(translateQti('choice.itemA', { target })))),
|
|
30
|
+
associableChoiceType.create({ identifier: `CHOICE_${crypto.randomUUID()}`, matchMax: 2 }, associableChoiceParagraphType.create(null, schema.text(translateQti('choice.itemB', { target })))),
|
|
31
|
+
associableChoiceType.create({ identifier: `CHOICE_${crypto.randomUUID()}`, matchMax: 2 }, associableChoiceParagraphType.create(null, schema.text(translateQti('choice.itemC', { target })))),
|
|
32
|
+
associableChoiceType.create({ identifier: `CHOICE_${crypto.randomUUID()}`, matchMax: 2 }, associableChoiceParagraphType.create(null, schema.text(translateQti('choice.option1', { target })))),
|
|
33
|
+
];
|
|
34
|
+
return interactionType.create({ responseIdentifier, maxAssociations: 3 }, [prompt, ...choices]);
|
|
35
|
+
},
|
|
36
|
+
selectionOffset: 2,
|
|
37
|
+
})(state, dispatch);
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Handles Enter inside qti-simple-associable-choice paragraphs by inserting a new empty
|
|
41
|
+
* sibling qti-simple-associable-choice directly after the current one.
|
|
42
|
+
*/
|
|
43
|
+
export const insertAssociableChoiceOnEnter = (state, dispatch) => {
|
|
44
|
+
const choiceType = state.schema.nodes.qtiSimpleAssociableChoice;
|
|
45
|
+
const paragraphType = state.schema.nodes.qtiSimpleAssociableChoiceParagraph;
|
|
46
|
+
const interactionType = state.schema.nodes.qtiAssociateInteraction;
|
|
47
|
+
if (!choiceType || !paragraphType || !interactionType)
|
|
48
|
+
return false;
|
|
49
|
+
// Only handle enter when inside qtiAssociateInteraction
|
|
50
|
+
const { selection } = state;
|
|
51
|
+
let insideAssociate = false;
|
|
52
|
+
for (let depth = selection.$from.depth; depth >= 0; depth--) {
|
|
53
|
+
if (selection.$from.node(depth).type === interactionType) {
|
|
54
|
+
insideAssociate = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!insideAssociate)
|
|
59
|
+
return false;
|
|
60
|
+
return createInsertSiblingOnEnterCommand({
|
|
61
|
+
ancestorNodeName: 'qtiSimpleAssociableChoice',
|
|
62
|
+
selectionOffset: 2,
|
|
63
|
+
createSiblingNode: () => choiceType.create({ identifier: `CHOICE_${crypto.randomUUID()}`, matchMax: 2 }, paragraphType.create()),
|
|
64
|
+
})(state, dispatch);
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Enter command chain for associate interactions.
|
|
68
|
+
* 1) Insert new associable choice when inside qti-associate-interaction.
|
|
69
|
+
* 2) Fallback to regular block split behavior elsewhere.
|
|
70
|
+
*/
|
|
71
|
+
export const qtiAssociateEnterCommand = chainCommands(insertAssociableChoiceOnEnter, splitBlock);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { InteractionComposeResult } from '@qti-editor/interaction-shared/composer/types.js';
|
|
2
|
+
export declare function composeAssociateInteractionElement(sourceElement: Element, xmlDoc: Document): InteractionComposeResult;
|
|
3
|
+
//# sourceMappingURL=qti-associate-interaction.compose.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qti-associate-interaction.compose.d.ts","sourceRoot":"","sources":["../../../src/components/qti-associate-interaction/qti-associate-interaction.compose.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAmB,wBAAwB,EAAkC,MAAM,kDAAkD,CAAC;AAclJ,wBAAgB,kCAAkC,CAAC,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,GAAG,wBAAwB,CA6CrH"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { associateInteractionComposerMetadata } from '../../composer/metadata.js';
|
|
2
|
+
function toFiniteNumber(value, fallback) {
|
|
3
|
+
if (value == null || value.trim().length === 0)
|
|
4
|
+
return fallback;
|
|
5
|
+
const parsed = Number(value);
|
|
6
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
7
|
+
}
|
|
8
|
+
function toNonEmptyString(value) {
|
|
9
|
+
if (!value)
|
|
10
|
+
return null;
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
13
|
+
}
|
|
14
|
+
export function composeAssociateInteractionElement(sourceElement, xmlDoc) {
|
|
15
|
+
const metadata = associateInteractionComposerMetadata;
|
|
16
|
+
const warnings = [];
|
|
17
|
+
const normalizedElement = xmlDoc.importNode(sourceElement, true);
|
|
18
|
+
const responseIdentifier = toNonEmptyString(sourceElement.getAttribute('response-identifier'));
|
|
19
|
+
const correctResponse = toNonEmptyString(sourceElement.getAttribute('correct-response'));
|
|
20
|
+
const maxAssociations = toFiniteNumber(sourceElement.getAttribute('max-associations'), 1);
|
|
21
|
+
const minAssociations = toFiniteNumber(sourceElement.getAttribute('min-associations'), 0);
|
|
22
|
+
const editorOnlyAttributes = [...metadata.editorOnlyAttributes];
|
|
23
|
+
editorOnlyAttributes.forEach(attr => normalizedElement.removeAttribute(attr));
|
|
24
|
+
normalizedElement.setAttribute('max-associations', String(maxAssociations > 0 ? maxAssociations : 1));
|
|
25
|
+
if (minAssociations > 0) {
|
|
26
|
+
normalizedElement.setAttribute('min-associations', String(minAssociations));
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
normalizedElement.removeAttribute('min-associations');
|
|
30
|
+
}
|
|
31
|
+
let responseDeclaration;
|
|
32
|
+
if (!responseIdentifier) {
|
|
33
|
+
warnings.push({
|
|
34
|
+
code: 'MISSING_RESPONSE_IDENTIFIER',
|
|
35
|
+
message: 'qti-associate-interaction is missing response-identifier; declaration will be skipped.',
|
|
36
|
+
tagName: metadata.tagName,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
responseDeclaration = {
|
|
41
|
+
identifier: responseIdentifier,
|
|
42
|
+
cardinality: maxAssociations > 1 ? 'multiple' : 'single',
|
|
43
|
+
baseType: 'identifier',
|
|
44
|
+
correctResponse: correctResponse ?? undefined,
|
|
45
|
+
sourceTag: metadata.tagName,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
normalizedElement,
|
|
50
|
+
responseDeclaration,
|
|
51
|
+
responseProcessingTemplate: metadata.responseProcessingTemplate,
|
|
52
|
+
responseProcessingKind: metadata.responseProcessing.internalKind,
|
|
53
|
+
editorOnlyAttributes,
|
|
54
|
+
warnings,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Interaction } from '@qti-editor/interaction-shared/components/interaction.js';
|
|
2
|
+
/** Association pair: [firstIdentifier, secondIdentifier] */
|
|
3
|
+
export type AssociatePair = [string, string];
|
|
4
|
+
/**
|
|
5
|
+
* Event detail for associate pair changes.
|
|
6
|
+
*/
|
|
7
|
+
export interface AssociatePairChangeDetail {
|
|
8
|
+
pairs: AssociatePair[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Editor component for qti-associate-interaction.
|
|
12
|
+
* Choices are shown in a pool; click a choice then click a drop slot to form a pair.
|
|
13
|
+
*/
|
|
14
|
+
export declare class QtiAssociateInteractionEdit extends Interaction {
|
|
15
|
+
static styles: import("lit").CSSResultGroup;
|
|
16
|
+
private readonly i18n;
|
|
17
|
+
maxAssociations: number;
|
|
18
|
+
minAssociations: number;
|
|
19
|
+
shuffle: boolean;
|
|
20
|
+
correctResponse: string | null;
|
|
21
|
+
private _renderTrigger;
|
|
22
|
+
private _setupDone;
|
|
23
|
+
private _labelCache;
|
|
24
|
+
private _observer;
|
|
25
|
+
private _lastEmittedResponse;
|
|
26
|
+
private get _state();
|
|
27
|
+
connectedCallback(): void;
|
|
28
|
+
disconnectedCallback(): void;
|
|
29
|
+
firstUpdated(): void;
|
|
30
|
+
private _onSlotChange;
|
|
31
|
+
private _trySetup;
|
|
32
|
+
private _setupMutationObserver;
|
|
33
|
+
private _observeMutations;
|
|
34
|
+
private _parseCorrectResponse;
|
|
35
|
+
private _serializeCurrentPairs;
|
|
36
|
+
private _syncStateFromCorrectResponse;
|
|
37
|
+
private _emitChange;
|
|
38
|
+
private _triggerRender;
|
|
39
|
+
private _getInteractionKey;
|
|
40
|
+
private _getChoices;
|
|
41
|
+
private _buildLabelCache;
|
|
42
|
+
private _getLabel;
|
|
43
|
+
private _onKeyDown;
|
|
44
|
+
private _onChoiceClick;
|
|
45
|
+
private _clearSlot;
|
|
46
|
+
private _placeInSlot;
|
|
47
|
+
/**
|
|
48
|
+
* Ensure there is always exactly one empty container at the end,
|
|
49
|
+
* and no completely empty containers in the middle.
|
|
50
|
+
*/
|
|
51
|
+
private _normalizeContainers;
|
|
52
|
+
private _cancelPending;
|
|
53
|
+
private _renderDropContainer;
|
|
54
|
+
updated(changedProperties: Map<string, unknown>): void;
|
|
55
|
+
render(): import("lit").TemplateResult<1>;
|
|
56
|
+
}
|
|
57
|
+
declare global {
|
|
58
|
+
interface HTMLElementEventMap {
|
|
59
|
+
'associate-pair-change': CustomEvent<AssociatePairChangeDetail>;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=qti-associate-interaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qti-associate-interaction.d.ts","sourceRoot":"","sources":["../../../src/components/qti-associate-interaction/qti-associate-interaction.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,0DAA0D,CAAC;AAKvF,4DAA4D;AAC5D,MAAM,MAAM,aAAa,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE7C;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACxC,KAAK,EAAE,aAAa,EAAE,CAAC;CACxB;AA+BD;;;GAGG;AACH,qBAAa,2BAA4B,SAAQ,WAAW;IAC1D,OAAgB,MAAM,+BAAU;IAEhC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA+B;IAGpD,eAAe,EAAE,MAAM,CAAK;IAG5B,eAAe,EAAE,MAAM,CAAK;IAG5B,OAAO,EAAE,OAAO,CAAS;IAGzB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAQ;IAGtC,OAAO,CAAC,cAAc,CAAK;IAE3B,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,SAAS,CAAiC;IAClD,OAAO,CAAC,oBAAoB,CAAuB;IAEnD,OAAO,KAAK,MAAM,GAEjB;IAEQ,iBAAiB;IAOjB,oBAAoB;IASpB,YAAY;IAIrB,OAAO,CAAC,aAAa,CAEnB;IAEF,OAAO,CAAC,SAAS;IAYjB,OAAO,CAAC,sBAAsB;IAQ9B,OAAO,CAAC,iBAAiB;IAIzB,OAAO,CAAC,qBAAqB;IAsB7B,OAAO,CAAC,sBAAsB;IAK9B,OAAO,CAAC,6BAA6B;IAOrC,OAAO,CAAC,WAAW;IAUnB,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,gBAAgB;IAYxB,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,UAAU,CAIhB;IAEF,OAAO,CAAC,cAAc,CAmBpB;IAEF,OAAO,CAAC,UAAU;IAWlB,OAAO,CAAC,YAAY;IAsBpB;;;OAGG;IACH,OAAO,CAAC,oBAAoB;IAO5B,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,oBAAoB;IA4CnB,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC;IAW/C,MAAM;CAShB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,mBAAmB;QAC3B,uBAAuB,EAAE,WAAW,CAAC,yBAAyB,CAAC,CAAC;KACjE;CACF"}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { html, nothing } from 'lit';
|
|
8
|
+
import { property, state } from 'lit/decorators.js';
|
|
9
|
+
import { Interaction } from '@qti-editor/interaction-shared/components/interaction.js';
|
|
10
|
+
import { QtiI18nController } from '@qti-editor/interaction-shared';
|
|
11
|
+
import styles from './qti-associate-interaction.styles.js';
|
|
12
|
+
const associateStates = new Map();
|
|
13
|
+
function getState(key) {
|
|
14
|
+
if (!associateStates.has(key)) {
|
|
15
|
+
associateStates.set(key, { pendingId: null, containers: [{ left: null, right: null }] });
|
|
16
|
+
}
|
|
17
|
+
return associateStates.get(key);
|
|
18
|
+
}
|
|
19
|
+
function stateToPairs(containers) {
|
|
20
|
+
return containers
|
|
21
|
+
.filter(c => c.left !== null && c.right !== null)
|
|
22
|
+
.map(c => [c.left, c.right]);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Editor component for qti-associate-interaction.
|
|
26
|
+
* Choices are shown in a pool; click a choice then click a drop slot to form a pair.
|
|
27
|
+
*/
|
|
28
|
+
export class QtiAssociateInteractionEdit extends Interaction {
|
|
29
|
+
constructor() {
|
|
30
|
+
super(...arguments);
|
|
31
|
+
this.i18n = new QtiI18nController(this);
|
|
32
|
+
this.maxAssociations = 1;
|
|
33
|
+
this.minAssociations = 0;
|
|
34
|
+
this.shuffle = false;
|
|
35
|
+
this.correctResponse = null;
|
|
36
|
+
this._renderTrigger = 0;
|
|
37
|
+
this._setupDone = false;
|
|
38
|
+
this._labelCache = new Map();
|
|
39
|
+
this._observer = null;
|
|
40
|
+
this._lastEmittedResponse = null;
|
|
41
|
+
this._onSlotChange = () => {
|
|
42
|
+
this._trySetup();
|
|
43
|
+
};
|
|
44
|
+
// ─── Event Handling ─────────────────────────────────────────────────────
|
|
45
|
+
this._onKeyDown = (e) => {
|
|
46
|
+
if (e.key === 'Escape' && this._state.pendingId) {
|
|
47
|
+
this._cancelPending();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
this._onChoiceClick = (e) => {
|
|
51
|
+
const path = e.composedPath();
|
|
52
|
+
const choiceIndex = path.findIndex(el => el instanceof HTMLElement && el.tagName === 'QTI-SIMPLE-ASSOCIABLE-CHOICE');
|
|
53
|
+
if (choiceIndex < 0)
|
|
54
|
+
return;
|
|
55
|
+
const choice = path[choiceIndex];
|
|
56
|
+
const identifier = choice.getAttribute('identifier');
|
|
57
|
+
if (!identifier)
|
|
58
|
+
return;
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
const state = this._state;
|
|
61
|
+
if (state.pendingId === identifier) {
|
|
62
|
+
state.pendingId = null;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
state.pendingId = identifier;
|
|
66
|
+
}
|
|
67
|
+
this._triggerRender();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
get _state() {
|
|
71
|
+
return getState(this._getInteractionKey());
|
|
72
|
+
}
|
|
73
|
+
connectedCallback() {
|
|
74
|
+
super.connectedCallback();
|
|
75
|
+
this._syncStateFromCorrectResponse();
|
|
76
|
+
document.addEventListener('keydown', this._onKeyDown);
|
|
77
|
+
requestAnimationFrame(() => this._trySetup());
|
|
78
|
+
}
|
|
79
|
+
disconnectedCallback() {
|
|
80
|
+
super.disconnectedCallback();
|
|
81
|
+
this.removeEventListener('click', this._onChoiceClick);
|
|
82
|
+
document.removeEventListener('keydown', this._onKeyDown);
|
|
83
|
+
this._observer?.disconnect();
|
|
84
|
+
this._observer = null;
|
|
85
|
+
this._setupDone = false;
|
|
86
|
+
}
|
|
87
|
+
firstUpdated() {
|
|
88
|
+
this._trySetup();
|
|
89
|
+
}
|
|
90
|
+
_trySetup() {
|
|
91
|
+
if (this._setupDone)
|
|
92
|
+
return;
|
|
93
|
+
const choices = this.querySelectorAll('qti-simple-associable-choice');
|
|
94
|
+
if (choices.length < 2)
|
|
95
|
+
return;
|
|
96
|
+
this._setupDone = true;
|
|
97
|
+
this._buildLabelCache();
|
|
98
|
+
this.addEventListener('click', this._onChoiceClick);
|
|
99
|
+
this._setupMutationObserver();
|
|
100
|
+
this._triggerRender();
|
|
101
|
+
}
|
|
102
|
+
_setupMutationObserver() {
|
|
103
|
+
this._observer = new MutationObserver(() => {
|
|
104
|
+
this._buildLabelCache();
|
|
105
|
+
this.requestUpdate();
|
|
106
|
+
});
|
|
107
|
+
this._observeMutations();
|
|
108
|
+
}
|
|
109
|
+
_observeMutations() {
|
|
110
|
+
this._observer?.observe(this, { childList: true, subtree: true, characterData: true });
|
|
111
|
+
}
|
|
112
|
+
_parseCorrectResponse() {
|
|
113
|
+
const state = this._state;
|
|
114
|
+
if (!this.correctResponse) {
|
|
115
|
+
state.containers = [{ left: null, right: null }];
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(this.correctResponse);
|
|
120
|
+
if (Array.isArray(parsed)) {
|
|
121
|
+
const valid = parsed.filter(p => Array.isArray(p) && p.length === 2 && p[0] && p[1]);
|
|
122
|
+
state.containers = [
|
|
123
|
+
...valid.map(([l, r]) => ({ left: l, right: r })),
|
|
124
|
+
{ left: null, right: null },
|
|
125
|
+
];
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Invalid JSON, ignore
|
|
131
|
+
}
|
|
132
|
+
state.containers = [{ left: null, right: null }];
|
|
133
|
+
}
|
|
134
|
+
_serializeCurrentPairs() {
|
|
135
|
+
const pairs = stateToPairs(this._state.containers);
|
|
136
|
+
return pairs.length > 0 ? JSON.stringify(pairs) : null;
|
|
137
|
+
}
|
|
138
|
+
_syncStateFromCorrectResponse() {
|
|
139
|
+
if (this.correctResponse === this._serializeCurrentPairs()) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this._parseCorrectResponse();
|
|
143
|
+
}
|
|
144
|
+
_emitChange() {
|
|
145
|
+
const pairs = stateToPairs(this._state.containers);
|
|
146
|
+
this._lastEmittedResponse = pairs.length > 0 ? JSON.stringify(pairs) : null;
|
|
147
|
+
this.dispatchEvent(new CustomEvent('associate-pair-change', {
|
|
148
|
+
detail: { pairs },
|
|
149
|
+
bubbles: true,
|
|
150
|
+
composed: true
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
_triggerRender() {
|
|
154
|
+
this._renderTrigger++;
|
|
155
|
+
}
|
|
156
|
+
_getInteractionKey() {
|
|
157
|
+
return this.responseIdentifier || this.getAttribute('response-identifier') || 'default';
|
|
158
|
+
}
|
|
159
|
+
_getChoices() {
|
|
160
|
+
return Array.from(this.querySelectorAll('qti-simple-associable-choice'));
|
|
161
|
+
}
|
|
162
|
+
_buildLabelCache() {
|
|
163
|
+
this._labelCache.clear();
|
|
164
|
+
for (const choice of this._getChoices()) {
|
|
165
|
+
const id = choice.getAttribute('identifier');
|
|
166
|
+
if (id) {
|
|
167
|
+
const clone = choice.cloneNode(true);
|
|
168
|
+
clone.querySelectorAll('qti-simple-associable-choice').forEach(el => el.remove());
|
|
169
|
+
this._labelCache.set(id, clone.textContent?.trim() || id);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
_getLabel(id) {
|
|
174
|
+
return this._labelCache.get(id) || id;
|
|
175
|
+
}
|
|
176
|
+
_clearSlot(containerIndex, side, e) {
|
|
177
|
+
e.stopPropagation();
|
|
178
|
+
const state = this._state;
|
|
179
|
+
const container = state.containers[containerIndex];
|
|
180
|
+
if (!container)
|
|
181
|
+
return;
|
|
182
|
+
container[side] = null;
|
|
183
|
+
this._normalizeContainers();
|
|
184
|
+
this._emitChange();
|
|
185
|
+
this._triggerRender();
|
|
186
|
+
}
|
|
187
|
+
_placeInSlot(containerIndex, side, e) {
|
|
188
|
+
e.stopPropagation();
|
|
189
|
+
const state = this._state;
|
|
190
|
+
const container = state.containers[containerIndex];
|
|
191
|
+
if (!container)
|
|
192
|
+
return;
|
|
193
|
+
if (container[side] !== null)
|
|
194
|
+
return; // slot is filled — do nothing
|
|
195
|
+
if (state.pendingId === null)
|
|
196
|
+
return;
|
|
197
|
+
// Remove pendingId from any existing slot
|
|
198
|
+
for (const c of state.containers) {
|
|
199
|
+
if (c.left === state.pendingId)
|
|
200
|
+
c.left = null;
|
|
201
|
+
if (c.right === state.pendingId)
|
|
202
|
+
c.right = null;
|
|
203
|
+
}
|
|
204
|
+
container[side] = state.pendingId;
|
|
205
|
+
state.pendingId = null;
|
|
206
|
+
this._normalizeContainers();
|
|
207
|
+
this._emitChange();
|
|
208
|
+
this._triggerRender();
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Ensure there is always exactly one empty container at the end,
|
|
212
|
+
* and no completely empty containers in the middle.
|
|
213
|
+
*/
|
|
214
|
+
_normalizeContainers() {
|
|
215
|
+
const state = this._state;
|
|
216
|
+
// Remove fully empty containers except the last one
|
|
217
|
+
state.containers = state.containers.filter(c => c.left !== null || c.right !== null);
|
|
218
|
+
state.containers.push({ left: null, right: null });
|
|
219
|
+
}
|
|
220
|
+
_cancelPending() {
|
|
221
|
+
this._state.pendingId = null;
|
|
222
|
+
this._triggerRender();
|
|
223
|
+
}
|
|
224
|
+
// ─── Render ─────────────────────────────────────────────────────────────
|
|
225
|
+
_renderDropContainer() {
|
|
226
|
+
const state = this._state;
|
|
227
|
+
const hasPending = state.pendingId !== null;
|
|
228
|
+
return html `
|
|
229
|
+
<div class="drop-container">
|
|
230
|
+
${hasPending ? html `
|
|
231
|
+
<div class="pending-banner">
|
|
232
|
+
<span>${this.i18n.t('associate.selectSlot', { label: this._getLabel(state.pendingId) })}</span>
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
class="pending-cancel"
|
|
236
|
+
aria-label=${this.i18n.t('associate.cancel')}
|
|
237
|
+
@click=${(e) => { e.stopPropagation(); this._cancelPending(); }}
|
|
238
|
+
>×</button>
|
|
239
|
+
</div>
|
|
240
|
+
` : nothing}
|
|
241
|
+
${state.containers.map((container, i) => html `
|
|
242
|
+
<div class="associables-container">
|
|
243
|
+
<div
|
|
244
|
+
class="drop-slot ${container.left !== null ? 'filled' : hasPending ? 'droppable' : ''}"
|
|
245
|
+
@click=${container.left === null ? (e) => this._placeInSlot(i, 'left', e) : nothing}
|
|
246
|
+
>
|
|
247
|
+
${container.left !== null
|
|
248
|
+
? html `<span>${this._getLabel(container.left)}</span><button type="button" class="slot-remove" @click=${(e) => this._clearSlot(i, 'left', e)}>×</button>`
|
|
249
|
+
: html `<span>${this.i18n.t('associate.dropHere')}</span>`}
|
|
250
|
+
</div>
|
|
251
|
+
<span class="slot-arrow">↔</span>
|
|
252
|
+
<div
|
|
253
|
+
class="drop-slot ${container.right !== null ? 'filled' : hasPending ? 'droppable' : ''}"
|
|
254
|
+
@click=${container.right === null ? (e) => this._placeInSlot(i, 'right', e) : nothing}
|
|
255
|
+
>
|
|
256
|
+
${container.right !== null
|
|
257
|
+
? html `<span>${this._getLabel(container.right)}</span><button type="button" class="slot-remove" @click=${(e) => this._clearSlot(i, 'right', e)}>×</button>`
|
|
258
|
+
: html `<span>${this.i18n.t('associate.dropHere')}</span>`}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
`)}
|
|
262
|
+
</div>
|
|
263
|
+
`;
|
|
264
|
+
}
|
|
265
|
+
updated(changedProperties) {
|
|
266
|
+
super.updated(changedProperties);
|
|
267
|
+
if (changedProperties.has('correctResponse')) {
|
|
268
|
+
// Skip re-parsing when the attribute change was triggered by our own _emitChange
|
|
269
|
+
if (this.correctResponse !== this._lastEmittedResponse) {
|
|
270
|
+
this._syncStateFromCorrectResponse();
|
|
271
|
+
this._triggerRender();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
render() {
|
|
276
|
+
void this._renderTrigger;
|
|
277
|
+
return html `
|
|
278
|
+
<slot name="prompt"></slot>
|
|
279
|
+
<slot @slotchange=${this._onSlotChange}></slot>
|
|
280
|
+
${this._setupDone ? this._renderDropContainer() : nothing}
|
|
281
|
+
`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
QtiAssociateInteractionEdit.styles = styles;
|
|
285
|
+
__decorate([
|
|
286
|
+
property({ type: Number, attribute: 'max-associations' })
|
|
287
|
+
], QtiAssociateInteractionEdit.prototype, "maxAssociations", void 0);
|
|
288
|
+
__decorate([
|
|
289
|
+
property({ type: Number, attribute: 'min-associations' })
|
|
290
|
+
], QtiAssociateInteractionEdit.prototype, "minAssociations", void 0);
|
|
291
|
+
__decorate([
|
|
292
|
+
property({ type: Boolean })
|
|
293
|
+
], QtiAssociateInteractionEdit.prototype, "shuffle", void 0);
|
|
294
|
+
__decorate([
|
|
295
|
+
property({ type: String, attribute: 'correct-response' })
|
|
296
|
+
], QtiAssociateInteractionEdit.prototype, "correctResponse", void 0);
|
|
297
|
+
__decorate([
|
|
298
|
+
state()
|
|
299
|
+
], QtiAssociateInteractionEdit.prototype, "_renderTrigger", void 0);
|
|
300
|
+
if (!customElements.get('qti-associate-interaction')) {
|
|
301
|
+
customElements.define('qti-associate-interaction', QtiAssociateInteractionEdit);
|
|
302
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qti-associate-interaction.schema.d.ts","sourceRoot":"","sources":["../../../src/components/qti-associate-interaction/qti-associate-interaction.schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAEjE,eAAO,MAAM,+BAA+B,EAAE,QA+C7C,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const qtiAssociateInteractionNodeSpec = {
|
|
2
|
+
group: 'block',
|
|
3
|
+
content: 'qtiPrompt? qtiSimpleAssociableChoice+',
|
|
4
|
+
attrs: {
|
|
5
|
+
maxAssociations: { default: 1 },
|
|
6
|
+
minAssociations: { default: 0 },
|
|
7
|
+
shuffle: { default: false },
|
|
8
|
+
class: { default: null },
|
|
9
|
+
correctResponse: { default: null },
|
|
10
|
+
responseIdentifier: { default: null }
|
|
11
|
+
},
|
|
12
|
+
parseDOM: [
|
|
13
|
+
{
|
|
14
|
+
tag: 'qti-associate-interaction',
|
|
15
|
+
getAttrs: (node) => {
|
|
16
|
+
if (!(node instanceof HTMLElement))
|
|
17
|
+
return {};
|
|
18
|
+
const maxAssociations = node.getAttribute('max-associations');
|
|
19
|
+
const minAssociations = node.getAttribute('min-associations');
|
|
20
|
+
const className = node.getAttribute('class');
|
|
21
|
+
return {
|
|
22
|
+
maxAssociations: maxAssociations ? parseInt(maxAssociations, 10) : 1,
|
|
23
|
+
minAssociations: minAssociations ? parseInt(minAssociations, 10) : 0,
|
|
24
|
+
shuffle: node.getAttribute('shuffle') === 'true',
|
|
25
|
+
class: className || null,
|
|
26
|
+
correctResponse: node.getAttribute('correct-response'),
|
|
27
|
+
responseIdentifier: node.getAttribute('response-identifier')
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
toDOM(node) {
|
|
33
|
+
const attrs = {
|
|
34
|
+
'max-associations': String(node.attrs.maxAssociations)
|
|
35
|
+
};
|
|
36
|
+
if (node.attrs.minAssociations > 0) {
|
|
37
|
+
attrs['min-associations'] = String(node.attrs.minAssociations);
|
|
38
|
+
}
|
|
39
|
+
if (node.attrs.shuffle) {
|
|
40
|
+
attrs['shuffle'] = 'true';
|
|
41
|
+
}
|
|
42
|
+
if (node.attrs.class)
|
|
43
|
+
attrs.class = node.attrs.class;
|
|
44
|
+
if (node.attrs.correctResponse)
|
|
45
|
+
attrs['correct-response'] = node.attrs.correctResponse;
|
|
46
|
+
if (node.attrs.responseIdentifier)
|
|
47
|
+
attrs['response-identifier'] = node.attrs.responseIdentifier;
|
|
48
|
+
return ['qti-associate-interaction', attrs, 0];
|
|
49
|
+
},
|
|
50
|
+
defining: true,
|
|
51
|
+
isolating: true
|
|
52
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qti-associate-interaction.styles.d.ts","sourceRoot":"","sources":["../../../src/components/qti-associate-interaction/qti-associate-interaction.styles.ts"],"names":[],"mappings":"AAAA,OAAO,EAAO,KAAK,cAAc,EAAE,MAAM,KAAK,CAAC;AAI/C,QAAA,MAAM,MAAM,EAAE,cAoJb,CAAC;AAEF,eAAe,MAAM,CAAC"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { css } from 'lit';
|
|
2
|
+
import externalStyles from '@qti-components/associate-interaction/styles';
|
|
3
|
+
const styles = [
|
|
4
|
+
externalStyles,
|
|
5
|
+
css `
|
|
6
|
+
:host {
|
|
7
|
+
display: block;
|
|
8
|
+
white-space: normal;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
slot:not([name]) {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
gap: 8px;
|
|
15
|
+
margin-top: 8px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
::slotted(qti-simple-associable-choice) {
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
cursor: pointer;
|
|
22
|
+
border-radius: 4px;
|
|
23
|
+
padding: 4px 8px;
|
|
24
|
+
border: 1px solid var(--qti-border, #cbd5e1);
|
|
25
|
+
min-height: 2rem;
|
|
26
|
+
min-width: 10rem;
|
|
27
|
+
box-sizing: border-box;
|
|
28
|
+
vertical-align: top;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
::slotted(qti-simple-associable-choice:hover) {
|
|
32
|
+
background: var(--qti-bg-hover, #f1f5f9);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.drop-container {
|
|
36
|
+
margin-top: 12px;
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
gap: 6px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.associables-container {
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: 8px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.drop-slot {
|
|
49
|
+
flex: 1;
|
|
50
|
+
min-height: 2rem;
|
|
51
|
+
padding: 4px 8px;
|
|
52
|
+
border: 1px dashed var(--qti-border, #cbd5e1);
|
|
53
|
+
border-radius: 4px;
|
|
54
|
+
background: #f8fafc;
|
|
55
|
+
font-size: 0.875em;
|
|
56
|
+
color: var(--qti-text-muted, #94a3b8);
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
transition: background 0.15s, border-color 0.15s;
|
|
61
|
+
user-select: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.drop-slot:hover {
|
|
65
|
+
background: var(--qti-bg-hover, #f1f5f9);
|
|
66
|
+
border-color: var(--qti-border-focus, #94a3b8);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.drop-slot.droppable {
|
|
70
|
+
border-color: var(--qti-border-warning, #f59e0b);
|
|
71
|
+
background: var(--qti-bg-warning, #fef3c7);
|
|
72
|
+
color: var(--qti-text-warning, #92400e);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.drop-slot.filled {
|
|
76
|
+
border-style: solid;
|
|
77
|
+
border-color: var(--qti-border-success, #22c55e);
|
|
78
|
+
background: var(--qti-bg-success, #dcfce7);
|
|
79
|
+
color: var(--qti-text-success, #166534);
|
|
80
|
+
cursor: default;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.drop-slot.filled:hover {
|
|
84
|
+
background: #bbf7d0;
|
|
85
|
+
border-color: #16a34a;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.slot-arrow {
|
|
89
|
+
color: var(--qti-text-muted, #94a3b8);
|
|
90
|
+
flex-shrink: 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.slot-remove {
|
|
94
|
+
display: inline-flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: center;
|
|
97
|
+
width: 16px;
|
|
98
|
+
height: 16px;
|
|
99
|
+
margin-left: auto;
|
|
100
|
+
padding: 0;
|
|
101
|
+
border: none;
|
|
102
|
+
background: transparent;
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
border-radius: 50%;
|
|
105
|
+
font-size: 13px;
|
|
106
|
+
line-height: 1;
|
|
107
|
+
color: inherit;
|
|
108
|
+
opacity: 0.6;
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.slot-remove:hover {
|
|
113
|
+
opacity: 1;
|
|
114
|
+
background: rgba(0, 0, 0, 0.1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.pending-banner {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: 8px;
|
|
121
|
+
padding: 4px 10px;
|
|
122
|
+
background: var(--qti-bg-warning, #fef3c7);
|
|
123
|
+
border: 1px dashed var(--qti-border-warning, #f59e0b);
|
|
124
|
+
border-radius: 4px;
|
|
125
|
+
font-size: 0.85em;
|
|
126
|
+
color: var(--qti-text-warning, #92400e);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.pending-cancel {
|
|
130
|
+
display: inline-flex;
|
|
131
|
+
align-items: center;
|
|
132
|
+
justify-content: center;
|
|
133
|
+
width: 18px;
|
|
134
|
+
height: 18px;
|
|
135
|
+
padding: 0;
|
|
136
|
+
border: none;
|
|
137
|
+
background: transparent;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
border-radius: 50%;
|
|
140
|
+
font-size: 14px;
|
|
141
|
+
line-height: 1;
|
|
142
|
+
color: inherit;
|
|
143
|
+
opacity: 0.6;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.pending-cancel:hover {
|
|
147
|
+
opacity: 1;
|
|
148
|
+
background: rgba(0, 0, 0, 0.1);
|
|
149
|
+
}
|
|
150
|
+
`
|
|
151
|
+
];
|
|
152
|
+
export default styles;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/composer/handler.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,kDAAkD,CAAC;AAEnG,eAAO,MAAM,wBAAwB,EAAE,0BAGtC,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { composeAssociateInteractionElement } from '../components/qti-associate-interaction/qti-associate-interaction.compose.js';
|
|
2
|
+
import { ASSOCIATE_INTERACTION_TAG } from './metadata.js';
|
|
3
|
+
export const associateComposerHandler = {
|
|
4
|
+
tagName: ASSOCIATE_INTERACTION_TAG,
|
|
5
|
+
compose: composeAssociateInteractionElement,
|
|
6
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const ASSOCIATE_INTERACTION_TAG: "qti-associate-interaction";
|
|
2
|
+
export declare const ASSOCIATE_INTERACTION_NODE_TYPE: "qtiAssociateInteraction";
|
|
3
|
+
export declare const associateInteractionComposerMetadata: {
|
|
4
|
+
tagName: "qti-associate-interaction";
|
|
5
|
+
nodeTypeName: "qtiAssociateInteraction";
|
|
6
|
+
responseProcessingTemplate: string;
|
|
7
|
+
responseProcessing: {
|
|
8
|
+
templateUri: string;
|
|
9
|
+
internalKind: "map_response";
|
|
10
|
+
internalSourceXml: string;
|
|
11
|
+
};
|
|
12
|
+
editorOnlyAttributes: string[];
|
|
13
|
+
userEditableAttributes: string[];
|
|
14
|
+
};
|
|
15
|
+
export declare const associateNodeAttributePanelMetadataByNodeTypeName: {
|
|
16
|
+
[x: string]: {
|
|
17
|
+
nodeTypeName: "qtiAssociateInteraction";
|
|
18
|
+
editableAttributes: string[];
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=metadata.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata.d.ts","sourceRoot":"","sources":["../../src/composer/metadata.ts"],"names":[],"mappings":"AAsBA,eAAO,MAAM,yBAAyB,EAAG,2BAAoC,CAAC;AAC9E,eAAO,MAAM,+BAA+B,EAAG,yBAAkC,CAAC;AAElF,eAAO,MAAM,oCAAoC;;;;;;;;;;;CAWV,CAAC;AAExC,eAAO,MAAM,iDAAiD;;;;;CAKR,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const MAP_RESPONSE_TEMPLATE = 'https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response';
|
|
2
|
+
const MAP_RESPONSE_INTERNAL_TEMPLATE = `
|
|
3
|
+
<qti-response-condition>
|
|
4
|
+
<qti-response-if>
|
|
5
|
+
<qti-is-null>
|
|
6
|
+
<qti-variable identifier="$responseIdentifier"/>
|
|
7
|
+
</qti-is-null>
|
|
8
|
+
<qti-set-outcome-value identifier="SCORE">
|
|
9
|
+
<qti-base-value base-type="float">0</qti-base-value>
|
|
10
|
+
</qti-set-outcome-value>
|
|
11
|
+
</qti-response-if>
|
|
12
|
+
<qti-response-else>
|
|
13
|
+
<qti-set-outcome-value identifier="SCORE">
|
|
14
|
+
<qti-map-response identifier="$responseIdentifier"/>
|
|
15
|
+
</qti-set-outcome-value>
|
|
16
|
+
</qti-response-else>
|
|
17
|
+
</qti-response-condition>
|
|
18
|
+
`;
|
|
19
|
+
export const ASSOCIATE_INTERACTION_TAG = 'qti-associate-interaction';
|
|
20
|
+
export const ASSOCIATE_INTERACTION_NODE_TYPE = 'qtiAssociateInteraction';
|
|
21
|
+
export const associateInteractionComposerMetadata = {
|
|
22
|
+
tagName: ASSOCIATE_INTERACTION_TAG,
|
|
23
|
+
nodeTypeName: ASSOCIATE_INTERACTION_NODE_TYPE,
|
|
24
|
+
responseProcessingTemplate: MAP_RESPONSE_TEMPLATE,
|
|
25
|
+
responseProcessing: {
|
|
26
|
+
templateUri: MAP_RESPONSE_TEMPLATE,
|
|
27
|
+
internalKind: 'map_response',
|
|
28
|
+
internalSourceXml: MAP_RESPONSE_INTERNAL_TEMPLATE,
|
|
29
|
+
},
|
|
30
|
+
editorOnlyAttributes: ['class'],
|
|
31
|
+
userEditableAttributes: ['maxAssociations', 'minAssociations', 'shuffle', 'correctResponse'],
|
|
32
|
+
};
|
|
33
|
+
export const associateNodeAttributePanelMetadataByNodeTypeName = {
|
|
34
|
+
[ASSOCIATE_INTERACTION_NODE_TYPE.toLowerCase()]: {
|
|
35
|
+
nodeTypeName: ASSOCIATE_INTERACTION_NODE_TYPE,
|
|
36
|
+
editableAttributes: associateInteractionComposerMetadata.userEditableAttributes,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const associateInteractionDescriptor: {
|
|
2
|
+
tagName: string;
|
|
3
|
+
nodeTypeName: string;
|
|
4
|
+
nodeSpecs: {
|
|
5
|
+
name: string;
|
|
6
|
+
spec: import("prosemirror-model").NodeSpec;
|
|
7
|
+
}[];
|
|
8
|
+
insertCommand: import("prosemirror-state").Command;
|
|
9
|
+
keyboardShortcut: string;
|
|
10
|
+
enterCommand: import("prosemirror-state").Command;
|
|
11
|
+
composerMetadata: {
|
|
12
|
+
tagName: "qti-associate-interaction";
|
|
13
|
+
nodeTypeName: "qtiAssociateInteraction";
|
|
14
|
+
responseProcessingTemplate: string;
|
|
15
|
+
responseProcessing: {
|
|
16
|
+
templateUri: string;
|
|
17
|
+
internalKind: "map_response";
|
|
18
|
+
internalSourceXml: string;
|
|
19
|
+
};
|
|
20
|
+
editorOnlyAttributes: string[];
|
|
21
|
+
userEditableAttributes: string[];
|
|
22
|
+
};
|
|
23
|
+
composerHandler: import("@qti-editor/interfaces").InteractionComposerHandler;
|
|
24
|
+
attributePanelMetadata: {
|
|
25
|
+
[x: string]: {
|
|
26
|
+
nodeTypeName: "qtiAssociateInteraction";
|
|
27
|
+
editableAttributes: string[];
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=descriptor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"descriptor.d.ts","sourceRoot":"","sources":["../src/descriptor.ts"],"names":[],"mappings":"AAeA,eAAO,MAAM,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgBV,CAAC"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { qtiPromptNodeSpec, qtiPromptParagraphNodeSpec, qtiSimpleAssociableChoiceNodeSpec, qtiSimpleAssociableChoiceParagraphNodeSpec, } from '@qti-editor/interaction-shared';
|
|
2
|
+
import { insertAssociateInteraction, qtiAssociateEnterCommand } from './components/qti-associate-interaction/qti-associate-interaction.commands.js';
|
|
3
|
+
import { qtiAssociateInteractionNodeSpec } from './components/qti-associate-interaction/qti-associate-interaction.schema.js';
|
|
4
|
+
import { associateInteractionComposerMetadata, associateNodeAttributePanelMetadataByNodeTypeName } from './composer/metadata.js';
|
|
5
|
+
import { associateComposerHandler } from './composer/handler.js';
|
|
6
|
+
export const associateInteractionDescriptor = {
|
|
7
|
+
tagName: 'qti-associate-interaction',
|
|
8
|
+
nodeTypeName: 'qtiAssociateInteraction',
|
|
9
|
+
nodeSpecs: [
|
|
10
|
+
{ name: 'qtiAssociateInteraction', spec: qtiAssociateInteractionNodeSpec },
|
|
11
|
+
{ name: 'qtiPrompt', spec: qtiPromptNodeSpec },
|
|
12
|
+
{ name: 'qtiPromptParagraph', spec: qtiPromptParagraphNodeSpec },
|
|
13
|
+
{ name: 'qtiSimpleAssociableChoice', spec: qtiSimpleAssociableChoiceNodeSpec },
|
|
14
|
+
{ name: 'qtiSimpleAssociableChoiceParagraph', spec: qtiSimpleAssociableChoiceParagraphNodeSpec },
|
|
15
|
+
],
|
|
16
|
+
insertCommand: insertAssociateInteraction,
|
|
17
|
+
keyboardShortcut: 'Mod-Shift-a',
|
|
18
|
+
enterCommand: qtiAssociateEnterCommand,
|
|
19
|
+
composerMetadata: associateInteractionComposerMetadata,
|
|
20
|
+
composerHandler: associateComposerHandler,
|
|
21
|
+
attributePanelMetadata: associateNodeAttributePanelMetadataByNodeTypeName,
|
|
22
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Extension } from 'prosekit/core';
|
|
2
|
+
/**
|
|
3
|
+
* Event name for associate pair changes.
|
|
4
|
+
*/
|
|
5
|
+
export declare const ASSOCIATE_PAIR_CHANGE_EVENT = "associate-pair-change";
|
|
6
|
+
/**
|
|
7
|
+
* Creates a ProseMirror extension that persists associate interaction correct responses.
|
|
8
|
+
*
|
|
9
|
+
* When pairs change, this extension serializes them to JSON and updates the
|
|
10
|
+
* interaction node's correctResponse attribute.
|
|
11
|
+
*/
|
|
12
|
+
export declare function defineAssociateCorrectResponseExtension(): Extension;
|
|
13
|
+
//# sourceMappingURL=correct-response.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"correct-response.d.ts","sourceRoot":"","sources":["../../src/extensions/correct-response.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAQ7D;;GAEG;AACH,eAAO,MAAM,2BAA2B,0BAA0B,CAAC;AAyBnE;;;;;GAKG;AACH,wBAAgB,uCAAuC,IAAI,SAAS,CAmCnE"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { definePlugin } from 'prosekit/core';
|
|
2
|
+
import { Plugin, PluginKey } from 'prosekit/pm/state';
|
|
3
|
+
const associateCorrectResponsePluginKey = new PluginKey('associate-correct-response');
|
|
4
|
+
/**
|
|
5
|
+
* Event name for associate pair changes.
|
|
6
|
+
*/
|
|
7
|
+
export const ASSOCIATE_PAIR_CHANGE_EVENT = 'associate-pair-change';
|
|
8
|
+
/**
|
|
9
|
+
* Finds the ProseMirror node position for an associate interaction element.
|
|
10
|
+
*/
|
|
11
|
+
function findInteractionNodePos(view, interactionElement) {
|
|
12
|
+
const { state } = view;
|
|
13
|
+
let foundPos = null;
|
|
14
|
+
state.doc.descendants((node, pos) => {
|
|
15
|
+
if (foundPos !== null)
|
|
16
|
+
return false;
|
|
17
|
+
if (node.type.name === 'qtiAssociateInteraction') {
|
|
18
|
+
const domNode = view.nodeDOM(pos);
|
|
19
|
+
if (domNode === interactionElement || (domNode && domNode.contains(interactionElement))) {
|
|
20
|
+
foundPos = pos;
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
});
|
|
26
|
+
return foundPos;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Creates a ProseMirror extension that persists associate interaction correct responses.
|
|
30
|
+
*
|
|
31
|
+
* When pairs change, this extension serializes them to JSON and updates the
|
|
32
|
+
* interaction node's correctResponse attribute.
|
|
33
|
+
*/
|
|
34
|
+
export function defineAssociateCorrectResponseExtension() {
|
|
35
|
+
return definePlugin(() => new Plugin({
|
|
36
|
+
key: associateCorrectResponsePluginKey,
|
|
37
|
+
view(view) {
|
|
38
|
+
const handlePairChange = (event) => {
|
|
39
|
+
const customEvent = event;
|
|
40
|
+
const interactionElement = customEvent.target;
|
|
41
|
+
if (!interactionElement.matches('qti-associate-interaction'))
|
|
42
|
+
return;
|
|
43
|
+
const { pairs } = customEvent.detail;
|
|
44
|
+
const correctResponse = pairs.length > 0 ? JSON.stringify(pairs) : null;
|
|
45
|
+
const pos = findInteractionNodePos(view, interactionElement);
|
|
46
|
+
if (pos === null)
|
|
47
|
+
return;
|
|
48
|
+
const { state, dispatch } = view;
|
|
49
|
+
const node = state.doc.nodeAt(pos);
|
|
50
|
+
if (!node)
|
|
51
|
+
return;
|
|
52
|
+
dispatch(state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, correctResponse }));
|
|
53
|
+
};
|
|
54
|
+
view.dom.addEventListener(ASSOCIATE_PAIR_CHANGE_EVENT, handlePairChange);
|
|
55
|
+
return {
|
|
56
|
+
destroy() {
|
|
57
|
+
view.dom.removeEventListener(ASSOCIATE_PAIR_CHANGE_EVENT, handlePairChange);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/extensions/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,uCAAuC,EACvC,2BAA2B,GAC5B,MAAM,uBAAuB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { defineAssociateCorrectResponseExtension, ASSOCIATE_PAIR_CHANGE_EVENT, } from './correct-response.js';
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './components/qti-associate-interaction/qti-associate-interaction.js';
|
|
2
|
+
export * from './components/qti-associate-interaction/qti-associate-interaction.schema.js';
|
|
3
|
+
export * from './components/qti-associate-interaction/qti-associate-interaction.commands.js';
|
|
4
|
+
export * from './components/qti-associate-interaction/qti-associate-interaction.compose.js';
|
|
5
|
+
export * from './extensions/index.js';
|
|
6
|
+
export * from './composer/metadata.js';
|
|
7
|
+
export * from './composer/handler.js';
|
|
8
|
+
export * from './descriptor.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qEAAqE,CAAC;AACpF,cAAc,4EAA4E,CAAC;AAC3F,cAAc,8EAA8E,CAAC;AAC7F,cAAc,6EAA6E,CAAC;AAE5F,cAAc,uBAAuB,CAAC;AAEtC,cAAc,wBAAwB,CAAC;AACvC,cAAc,uBAAuB,CAAC;AACtC,cAAc,iBAAiB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './components/qti-associate-interaction/qti-associate-interaction.js';
|
|
2
|
+
export * from './components/qti-associate-interaction/qti-associate-interaction.schema.js';
|
|
3
|
+
export * from './components/qti-associate-interaction/qti-associate-interaction.commands.js';
|
|
4
|
+
export * from './components/qti-associate-interaction/qti-associate-interaction.compose.js';
|
|
5
|
+
export * from './extensions/index.js';
|
|
6
|
+
export * from './composer/metadata.js';
|
|
7
|
+
export * from './composer/handler.js';
|
|
8
|
+
export * from './descriptor.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qti-editor/interaction-associate",
|
|
3
|
+
"description": "QTI associate interaction schemas, components, commands, and composer",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./*": {
|
|
14
|
+
"types": "./dist/*",
|
|
15
|
+
"default": "./dist/*"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"clean": "rm -rf dist"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@qti-components/associate-interaction": "^1.1.3",
|
|
25
|
+
"@qti-components/interactions": "1.10.1",
|
|
26
|
+
"@qti-components/utilities": "1.3.2",
|
|
27
|
+
"@qti-editor/interaction-shared": "workspace:*",
|
|
28
|
+
"@qti-editor/interfaces": "workspace:*"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"lit": "^3.3.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"lit": "^3.3.2",
|
|
35
|
+
"prosemirror-commands": "^1.7.1",
|
|
36
|
+
"prosemirror-model": "^1.25.4",
|
|
37
|
+
"prosemirror-state": "^1.4.4"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|