@qti-editor/interaction-choice 0.1.0 → 0.2.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.
@@ -1 +1 @@
1
- {"version":3,"file":"convert-selection-to-choice.commands.d.ts","sourceRoot":"","sources":["../../../../src/components/qti-choice-interaction/commands/convert-selection-to-choice.commands.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAiInD,wBAAgB,qCAAqC,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAmB/E;AAED,wBAAgB,kCAAkC,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAmD5E"}
1
+ {"version":3,"file":"convert-selection-to-choice.commands.d.ts","sourceRoot":"","sources":["../../../../src/components/qti-choice-interaction/commands/convert-selection-to-choice.commands.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA8LnD,wBAAgB,qCAAqC,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAwB/E;AAED,wBAAgB,kCAAkC,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAiD5E"}
@@ -1,3 +1,4 @@
1
+ import { translateQti } from '@qti-editor/interaction-shared';
1
2
  function isFlatList(listNode, listType) {
2
3
  let hasNestedList = false;
3
4
  listNode.descendants((node, pos) => {
@@ -9,37 +10,46 @@ function isFlatList(listNode, listType) {
9
10
  });
10
11
  return !hasNestedList;
11
12
  }
12
- function isConvertibleRootBlock(node, schema) {
13
+ function hasOnlyTextInlineContent(node) {
14
+ for (let i = 0; i < node.childCount; i++) {
15
+ if (!node.child(i).isText)
16
+ return false;
17
+ }
18
+ return true;
19
+ }
20
+ function isPlainTextParagraph(node, schema) {
21
+ const paragraphType = schema.nodes.paragraph;
22
+ return Boolean(paragraphType && node.type === paragraphType && hasOnlyTextInlineContent(node));
23
+ }
24
+ function isConvertibleList(node, schema) {
13
25
  const paragraphType = schema.nodes.paragraph;
14
26
  const listType = schema.nodes.list;
15
- if (paragraphType && node.type === paragraphType) {
16
- return 'paragraph';
27
+ if (!paragraphType || !listType || node.type !== listType) {
28
+ return false;
17
29
  }
18
- if (listType && node.type === listType) {
19
- const listKind = node.attrs?.kind;
20
- if (!['bullet', 'ordered'].includes(listKind))
21
- return null;
22
- if (!isFlatList(node, listType))
23
- return null;
24
- return 'list';
30
+ const listKind = node.attrs?.kind;
31
+ if (!['bullet', 'ordered'].includes(listKind))
32
+ return false;
33
+ if (!isFlatList(node, listType))
34
+ return false;
35
+ for (let i = 0; i < node.childCount; i += 1) {
36
+ const child = node.child(i);
37
+ if (child.type !== paragraphType || !hasOnlyTextInlineContent(child)) {
38
+ return false;
39
+ }
25
40
  }
26
- return null;
41
+ return node.childCount > 0;
27
42
  }
28
- function getSelectedConvertibleBlocks(view) {
43
+ function getSelectedRootBlocks(view) {
29
44
  const { state } = view;
30
45
  const { selection } = state;
31
- const schema = state.schema;
32
46
  const blocks = [];
33
47
  const seenPositions = new Set();
34
- const addBlockIfConvertible = (node, start) => {
35
- if (seenPositions.has(start))
36
- return;
37
- const kind = isConvertibleRootBlock(node, schema);
38
- if (!kind)
48
+ const addBlock = (node, start) => {
49
+ if (seenPositions.has(start) || !node.isBlock)
39
50
  return;
40
51
  seenPositions.add(start);
41
52
  blocks.push({
42
- kind,
43
53
  pos: start,
44
54
  end: start + node.nodeSize,
45
55
  node,
@@ -72,20 +82,16 @@ function getSelectedConvertibleBlocks(view) {
72
82
  };
73
83
  if (selection.empty) {
74
84
  const rootBlock = getRootBlockAt(selection.from);
75
- if (!rootBlock)
76
- return blocks;
77
- addBlockIfConvertible(rootBlock.node, rootBlock.start);
85
+ if (rootBlock)
86
+ addBlock(rootBlock.node, rootBlock.start);
78
87
  return blocks;
79
88
  }
80
- // Block-select plugin uses a custom "node-range" selection with explicit block ranges.
81
- // Prefer those exact ranges to avoid text-range quirks across custom elements/shadow DOM.
82
89
  const selectionJSON = selection.toJSON();
83
90
  if (selectionJSON.type === 'node-range' && Array.isArray(selection.ranges)) {
84
91
  for (const range of selection.ranges) {
85
92
  const rootBlock = getRootBlockAt(range.$from.pos);
86
- if (!rootBlock)
87
- continue;
88
- addBlockIfConvertible(rootBlock.node, rootBlock.start);
93
+ if (rootBlock)
94
+ addBlock(rootBlock.node, rootBlock.start);
89
95
  }
90
96
  if (blocks.length > 0)
91
97
  return blocks;
@@ -96,22 +102,65 @@ function getSelectedConvertibleBlocks(view) {
96
102
  const intersectsSelection = end > selection.from && start < selection.to;
97
103
  if (!intersectsSelection)
98
104
  return;
99
- addBlockIfConvertible(node, start);
105
+ addBlock(node, start);
100
106
  });
101
107
  return blocks;
102
108
  }
103
- function getChoiceInlineContent(block, schema) {
104
- if (block.kind === 'paragraph') {
105
- return block.node.content;
109
+ function buildConversionPlan(blocks, schema) {
110
+ if (blocks.length === 0)
111
+ return null;
112
+ // All-paragraph mode: first paragraph → prompt, rest → choices
113
+ if (blocks.every(b => isPlainTextParagraph(b.node, schema))) {
114
+ if (blocks.length < 2)
115
+ return null;
116
+ const [promptBlock, ...choiceBlocks] = blocks;
117
+ return {
118
+ promptText: promptBlock.node.textContent.trim() || null,
119
+ blocksToReplace: blocks,
120
+ choiceContents: choiceBlocks.map(b => b.node.content.size > 0 ? b.node.content : null),
121
+ };
106
122
  }
107
- const paragraphType = schema.nodes.paragraph;
108
- if (!paragraphType)
123
+ // List mode: optional leading plain-text paragraphs as prompt, then list(s)
124
+ const promptBlocks = [];
125
+ const listBlocks = [];
126
+ let sawList = false;
127
+ for (const block of blocks) {
128
+ if (isPlainTextParagraph(block.node, schema)) {
129
+ if (sawList)
130
+ return null;
131
+ promptBlocks.push(block);
132
+ continue;
133
+ }
134
+ if (isConvertibleList(block.node, schema)) {
135
+ sawList = true;
136
+ listBlocks.push(block);
137
+ continue;
138
+ }
109
139
  return null;
110
- const firstChild = block.node.firstChild;
111
- if (firstChild && firstChild.type === paragraphType) {
112
- return firstChild.content;
113
140
  }
114
- return null;
141
+ if (listBlocks.length === 0)
142
+ return null;
143
+ const promptText = promptBlocks
144
+ .map(block => block.node.textContent.trim())
145
+ .filter(Boolean)
146
+ .join(' ')
147
+ .trim();
148
+ const paragraphType = schema.nodes.paragraph;
149
+ const choiceContents = listBlocks.flatMap(block => {
150
+ const contents = [];
151
+ for (let i = 0; i < block.node.childCount; i += 1) {
152
+ const child = block.node.child(i);
153
+ if (child.type !== paragraphType)
154
+ continue;
155
+ contents.push(child.content.size > 0 ? child.content : null);
156
+ }
157
+ return contents;
158
+ });
159
+ return {
160
+ promptText: promptText || null,
161
+ blocksToReplace: [...promptBlocks, ...listBlocks],
162
+ choiceContents,
163
+ };
115
164
  }
116
165
  export function canConvertFlatListToChoiceInteraction(view) {
117
166
  const { state } = view;
@@ -124,12 +173,16 @@ export function canConvertFlatListToChoiceInteraction(view) {
124
173
  if (!interactionType || !promptType || !promptParagraphType || !choiceType || !choiceParagraphType) {
125
174
  return false;
126
175
  }
127
- const selectedBlocks = getSelectedConvertibleBlocks(view);
128
- if (selectedBlocks.length === 0)
176
+ const plan = buildConversionPlan(getSelectedRootBlocks(view), schema);
177
+ if (!plan)
178
+ return false;
179
+ const firstBlock = plan.blocksToReplace[0];
180
+ const lastBlock = plan.blocksToReplace[plan.blocksToReplace.length - 1];
181
+ const $from = state.doc.resolve(firstBlock.pos);
182
+ const $to = state.doc.resolve(lastBlock.end);
183
+ if ($from.parent !== $to.parent)
129
184
  return false;
130
- const firstBlock = selectedBlocks[0];
131
- const $pos = state.doc.resolve(firstBlock.pos);
132
- return $pos.parent.canReplaceWith($pos.index(), $pos.index() + 1, interactionType);
185
+ return $from.parent.canReplaceWith($from.index(), $to.index(), interactionType);
133
186
  }
134
187
  export function convertFlatListToChoiceInteraction(view) {
135
188
  const { state } = view;
@@ -142,24 +195,26 @@ export function convertFlatListToChoiceInteraction(view) {
142
195
  if (!interactionType || !promptType || !promptParagraphType || !choiceType || !choiceParagraphType) {
143
196
  return false;
144
197
  }
145
- const selectedBlocks = getSelectedConvertibleBlocks(view);
146
- if (selectedBlocks.length === 0)
198
+ const plan = buildConversionPlan(getSelectedRootBlocks(view), schema);
199
+ if (!plan)
147
200
  return false;
148
- const prompt = promptType.create(null, promptParagraphType.create(null, schema.text('Select one option')));
149
- const choices = selectedBlocks.map(block => {
150
- const inlineContent = getChoiceInlineContent(block, schema);
151
- const paragraphContent = inlineContent ?? schema.text(block.node.textContent.trim() || 'Option');
201
+ const promptText = plan.promptText ?? translateQti('prompt.choice.selectOne', { target: view.dom });
202
+ const prompt = promptType.create(null, promptParagraphType.create(null, schema.text(promptText)));
203
+ const choices = plan.choiceContents.map(content => {
204
+ const paragraphContent = content ?? schema.text(translateQti('choice.option', { target: view.dom }));
152
205
  return choiceType.create({ identifier: `SIMPLE_CHOICE_${crypto.randomUUID()}` }, choiceParagraphType.create(null, paragraphContent));
153
206
  });
154
207
  const interaction = interactionType.create({ responseIdentifier: `RESPONSE_${crypto.randomUUID()}`, maxChoices: 1 }, [prompt, ...choices]);
155
- const firstBlock = selectedBlocks[0];
156
- const $pos = state.doc.resolve(firstBlock.pos);
157
- if (!$pos.parent.canReplaceWith($pos.index(), $pos.index() + 1, interactionType)) {
208
+ const firstBlock = plan.blocksToReplace[0];
209
+ const lastBlock = plan.blocksToReplace[plan.blocksToReplace.length - 1];
210
+ const $from = state.doc.resolve(firstBlock.pos);
211
+ const $to = state.doc.resolve(lastBlock.end);
212
+ if ($from.parent !== $to.parent || !$from.parent.canReplaceWith($from.index(), $to.index(), interactionType)) {
158
213
  return false;
159
214
  }
160
215
  const tr = state.tr;
161
- for (let i = selectedBlocks.length - 1; i >= 0; i -= 1) {
162
- const block = selectedBlocks[i];
216
+ for (let i = plan.blocksToReplace.length - 1; i >= 0; i -= 1) {
217
+ const block = plan.blocksToReplace[i];
163
218
  tr.delete(block.pos, block.end);
164
219
  }
165
220
  tr.insert(firstBlock.pos, interaction);
@@ -1 +1 @@
1
- {"version":3,"file":"insert-choice-interaction.commands.d.ts","sourceRoot":"","sources":["../../../../src/components/qti-choice-interaction/commands/insert-choice-interaction.commands.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEjD;;GAEG;AACH,eAAO,MAAM,uBAAuB,EAAE,OAoCrC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,EAAE,OAcvC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,EAAE,OAA8D,CAAC"}
1
+ {"version":3,"file":"insert-choice-interaction.commands.d.ts","sourceRoot":"","sources":["../../../../src/components/qti-choice-interaction/commands/insert-choice-interaction.commands.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAGjD;;GAEG;AACH,eAAO,MAAM,uBAAuB,EAAE,OAwCrC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,EAAE,OAcvC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,EAAE,OAA8D,CAAC"}
@@ -1,10 +1,12 @@
1
1
  import { chainCommands, splitBlock } from 'prosemirror-commands';
2
2
  import { createInsertSiblingOnEnterCommand } from '@qti-editor/interaction-shared/commands/enter.js';
3
3
  import { createInsertBlockInteractionCommand } from '@qti-editor/interaction-shared/commands/insert.js';
4
+ import { translateQti } from '@qti-editor/interaction-shared';
4
5
  /**
5
6
  * Command to insert a choice interaction at the current selection
6
7
  */
7
- export const insertChoiceInteraction = (state, dispatch) => {
8
+ export const insertChoiceInteraction = (state, dispatch, view) => {
9
+ const target = view?.dom ?? null;
8
10
  return createInsertBlockInteractionCommand({
9
11
  createNode: currentState => {
10
12
  const { schema } = currentState;
@@ -17,11 +19,11 @@ export const insertChoiceInteraction = (state, dispatch) => {
17
19
  return null;
18
20
  }
19
21
  const responseIdentifier = `RESPONSE_${crypto.randomUUID()}`;
20
- const prompt = promptType.create(null, promptParagraphType.create(null, schema.text('Which option is correct?')));
22
+ const prompt = promptType.create(null, promptParagraphType.create(null, schema.text(translateQti('prompt.choice.default', { target }))));
21
23
  const choices = [
22
- choiceType.create({ identifier: `SIMPLE_CHOICE_${crypto.randomUUID()}` }, choiceParagraphType.create(null, schema.text('Option A'))),
23
- choiceType.create({ identifier: `SIMPLE_CHOICE_${crypto.randomUUID()}` }, choiceParagraphType.create(null, schema.text('Option B'))),
24
- choiceType.create({ identifier: `SIMPLE_CHOICE_${crypto.randomUUID()}` }, choiceParagraphType.create(null, schema.text('Option C')))
24
+ choiceType.create({ identifier: `SIMPLE_CHOICE_${crypto.randomUUID()}` }, choiceParagraphType.create(null, schema.text(translateQti('choice.optionA', { target })))),
25
+ choiceType.create({ identifier: `SIMPLE_CHOICE_${crypto.randomUUID()}` }, choiceParagraphType.create(null, schema.text(translateQti('choice.optionB', { target })))),
26
+ choiceType.create({ identifier: `SIMPLE_CHOICE_${crypto.randomUUID()}` }, choiceParagraphType.create(null, schema.text(translateQti('choice.optionC', { target }))))
25
27
  ];
26
28
  return interactionType.create({ responseIdentifier, maxChoices: 1 }, [prompt, ...choices]);
27
29
  },
@@ -1 +1 @@
1
- {"version":3,"file":"qti-choice-interaction.d.ts","sourceRoot":"","sources":["../../../src/components/qti-choice-interaction/qti-choice-interaction.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,0DAA0D,CAAC;AAKvF,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,UAAU,GAAG,SAAS,CAAC;AAChE,QAAA,MAAM,qBAAqB,EAAwD,OAAO,WAAW,CAAC;AAEtG,qBAAa,wBAAyB,SAAQ,qBAAqB;;IACjE,WAAoB,MAAM,8BASzB;IAGM,UAAU,SAAK;IAGf,UAAU,SAAK;IAGf,OAAO,EAAE,0BAA0B,GAAG,4BAA4B,GAAG,SAAS,CAAC;IAEtF,SAAS,CAAC,UAAU,EAAE,gBAAgB,CAAC;;IAQvC,iBAAiB,IAAI,IAAI;IAOzB,oBAAoB,IAAI,IAAI;IAMnB,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC;IAiB/C,MAAM;CAGhB"}
1
+ {"version":3,"file":"qti-choice-interaction.d.ts","sourceRoot":"","sources":["../../../src/components/qti-choice-interaction/qti-choice-interaction.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,0DAA0D,CAAC;AAKvF,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,UAAU,GAAG,SAAS,CAAC;AAChE,QAAA,MAAM,qBAAqB,EAAwD,OAAO,WAAW,CAAC;AAEtG,qBAAa,wBAAyB,SAAQ,qBAAqB;;IACjE,WAAoB,MAAM,8BASzB;IAGD,UAAU,SAAK;IAGf,UAAU,SAAK;IAGf,OAAO,EAAE,0BAA0B,GAAG,4BAA4B,GAAG,SAAS,CAAC;IAE/E,SAAS,CAAC,UAAU,EAAE,gBAAgB,CAAC;;IAQvC,iBAAiB,IAAI,IAAI;IAOzB,oBAAoB,IAAI,IAAI;IAMnB,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC;IAmC/C,MAAM;CAGhB"}
@@ -15,7 +15,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
15
15
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
16
16
  return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
17
17
  };
18
- var _QtiChoiceInteractionEdit_instances, _QtiChoiceInteractionEdit_mutationObserver, _QtiChoiceInteractionEdit_updateChoices;
18
+ var _QtiChoiceInteractionEdit_instances, _QtiChoiceInteractionEdit_mutationObserver, _QtiChoiceInteractionEdit_syncSelectedChoices, _QtiChoiceInteractionEdit_updateChoices;
19
19
  import { css, html } from 'lit';
20
20
  import { property } from 'lit/decorators.js';
21
21
  import { Interaction } from '@qti-editor/interaction-shared/components/interaction.js';
@@ -57,12 +57,24 @@ export class QtiChoiceInteractionEdit extends ChoiceInteractionBase {
57
57
  if (changedProperties.has('maxChoices')) {
58
58
  __classPrivateFieldGet(this, _QtiChoiceInteractionEdit_instances, "m", _QtiChoiceInteractionEdit_updateChoices).call(this);
59
59
  }
60
+ if (changedProperties.has('correctResponse')) {
61
+ __classPrivateFieldGet(this, _QtiChoiceInteractionEdit_instances, "m", _QtiChoiceInteractionEdit_syncSelectedChoices).call(this);
62
+ }
60
63
  }
61
64
  render() {
62
65
  return html `<slot part="prompt" name="prompt"></slot><slot part="slot"></slot>`;
63
66
  }
64
67
  }
65
- _QtiChoiceInteractionEdit_mutationObserver = new WeakMap(), _QtiChoiceInteractionEdit_instances = new WeakSet(), _QtiChoiceInteractionEdit_updateChoices = function _QtiChoiceInteractionEdit_updateChoices() {
68
+ _QtiChoiceInteractionEdit_mutationObserver = new WeakMap(), _QtiChoiceInteractionEdit_instances = new WeakSet(), _QtiChoiceInteractionEdit_syncSelectedChoices = function _QtiChoiceInteractionEdit_syncSelectedChoices() {
69
+ const identifiers = new Set(typeof this.correctResponse === 'string' && this.correctResponse
70
+ ? this.correctResponse.split(',')
71
+ : Array.isArray(this.correctResponse)
72
+ ? this.correctResponse
73
+ : []);
74
+ this.querySelectorAll('qti-simple-choice').forEach(choice => {
75
+ choice.setSelected?.(identifiers.has(choice.identifier ?? ''));
76
+ });
77
+ }, _QtiChoiceInteractionEdit_updateChoices = function _QtiChoiceInteractionEdit_updateChoices() {
66
78
  this._internals.role = this.maxChoices === 1 ? 'radiogroup' : null;
67
79
  const role = this.maxChoices === 1 ? 'radio' : 'checkbox';
68
80
  this.querySelectorAll('qti-simple-choice').forEach((choice) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qti-editor/interaction-choice",
3
3
  "description": "QTI choice interaction schemas, components, commands, and composer",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -16,19 +16,19 @@
16
16
  }
17
17
  },
18
18
  "dependencies": {
19
- "@qti-components/base": "^1.4.1",
20
- "@qti-components/choice-interaction": "^1.1.0",
19
+ "@qti-components/base": "^1.4.2",
20
+ "@qti-components/choice-interaction": "^1.1.3",
21
21
  "@qti-components/interactions": "^1.10.1",
22
- "@qti-components/interactions-core": "^1.1.0",
23
- "@qti-components/utilities": "^1.3.1",
24
- "@qti-editor/interfaces": "0.1.0",
25
- "@qti-editor/interaction-shared": "0.1.0"
22
+ "@qti-components/interactions-core": "^1.1.3",
23
+ "@qti-components/utilities": "^1.3.2",
24
+ "@qti-editor/interaction-shared": "0.2.0",
25
+ "@qti-editor/interfaces": "0.2.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "lit": "^3.3.1"
29
29
  },
30
30
  "devDependencies": {
31
- "lit": "^3.3.1",
31
+ "lit": "^3.3.2",
32
32
  "prosemirror-commands": "^1.7.1",
33
33
  "prosemirror-model": "^1.25.4",
34
34
  "prosemirror-state": "^1.4.4"