@webwriter/quiz 1.1.3 → 2.0.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.
Files changed (167) hide show
  1. package/.github/workflows/publish.yml +8 -0
  2. package/LICENSE +6 -6
  3. package/README.md +778 -0
  4. package/assets/icons/grip-horizontal.svg +8 -0
  5. package/assets/icons/grip-vertical.svg +8 -0
  6. package/assets/icons/truefalse.svg +6 -0
  7. package/custom-elements.json +2779 -0
  8. package/custom.d.ts +180 -176
  9. package/dist/api/index.d.ts +27 -0
  10. package/dist/api/index.js +40 -0
  11. package/dist/migrate.js +1434 -0
  12. package/dist/widgets/webwriter-choice-item.js +4036 -3895
  13. package/dist/widgets/webwriter-choice.js +2269 -5821
  14. package/dist/widgets/webwriter-gap-item.js +3987 -0
  15. package/dist/widgets/webwriter-gap.js +2908 -0
  16. package/dist/widgets/webwriter-mark.js +4335 -3489
  17. package/dist/widgets/webwriter-order-item.js +4014 -3306
  18. package/dist/widgets/webwriter-order.js +2944 -4386
  19. package/dist/widgets/webwriter-pairing-item.js +1058 -2421
  20. package/dist/widgets/webwriter-pairing.js +2993 -4383
  21. package/dist/widgets/webwriter-quiz-hint.js +2469 -0
  22. package/dist/widgets/webwriter-quiz.js +4933 -6099
  23. package/dist/widgets/webwriter-speech.js +6077 -5230
  24. package/dist/widgets/webwriter-task-prompt.js +4775 -2165
  25. package/dist/widgets/webwriter-task.js +2364 -8382
  26. package/dist/widgets/webwriter-text.js +4169 -4337
  27. package/dist/widgets/webwriter-true-false.js +4674 -0
  28. package/editing-config.json +106 -0
  29. package/lit-localize.json +21 -15
  30. package/localization/de.xlf +407 -74
  31. package/localization/es.xlf +407 -69
  32. package/localization/fr.xlf +407 -69
  33. package/localization/generated/de.ts +112 -29
  34. package/localization/generated/es.ts +112 -29
  35. package/localization/generated/fr.ts +112 -29
  36. package/localization/generated/index.d.ts +5 -0
  37. package/localization/generated/index.js +2 -2
  38. package/localization/generated/it.ts +112 -29
  39. package/localization/generated/locale-codes.js +31 -79
  40. package/localization/generated/nl.ts +112 -29
  41. package/localization/it.xlf +407 -69
  42. package/localization/nl.xlf +407 -69
  43. package/package.json +119 -252
  44. package/scripts/apply-translations.mjs +97 -0
  45. package/src/api/index.ts +72 -0
  46. package/src/lib/artboard.ts +686 -0
  47. package/src/lib/draggable-grid.ts +432 -0
  48. package/src/lib/encrypted-property.ts +202 -0
  49. package/src/lib/hint-popup.ts +152 -0
  50. package/src/lib/utils.ts +97 -0
  51. package/src/lib/webwriter-quirks.ts +121 -0
  52. package/src/migrate/index.ts +622 -0
  53. package/src/migrate/legacy-solution.ts +362 -0
  54. package/src/snippets/order.html +9 -8
  55. package/src/widgets/choice/webwriter-choice-item.ts +316 -0
  56. package/src/widgets/choice/webwriter-choice.ts +328 -0
  57. package/src/widgets/choice/webwriter-true-false.ts +113 -0
  58. package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
  59. package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
  60. package/src/widgets/dnd/webwriter-dnd.ts +36 -0
  61. package/src/widgets/gap/webwriter-gap-item.ts +250 -0
  62. package/src/widgets/gap/webwriter-gap.ts +447 -0
  63. package/src/widgets/order/webwriter-order-item.ts +243 -0
  64. package/src/widgets/order/webwriter-order.ts +282 -0
  65. package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
  66. package/src/widgets/pairing/webwriter-pairing.ts +934 -0
  67. package/src/widgets/speech/audio-player.ts +325 -0
  68. package/src/widgets/speech/audio-recorder.ts +285 -0
  69. package/src/widgets/speech/waveform.ts +113 -0
  70. package/src/widgets/speech/webwriter-speech.ts +87 -0
  71. package/src/widgets/webwriter-mark.ts +374 -432
  72. package/src/widgets/webwriter-quiz-hint.ts +36 -0
  73. package/src/widgets/webwriter-quiz.ts +320 -282
  74. package/src/widgets/webwriter-task-prompt.ts +49 -24
  75. package/src/widgets/webwriter-task.ts +216 -547
  76. package/src/widgets/webwriter-text.ts +291 -156
  77. package/tsconfig.api.json +12 -0
  78. package/tsconfig.json +8 -7
  79. package/dist/widgets/webwriter-choice.css +0 -444
  80. package/dist/widgets/webwriter-cloze-gap.css +0 -444
  81. package/dist/widgets/webwriter-cloze-gap.js +0 -5866
  82. package/dist/widgets/webwriter-cloze.css +0 -444
  83. package/dist/widgets/webwriter-cloze.js +0 -3786
  84. package/dist/widgets/webwriter-mark.css +0 -444
  85. package/dist/widgets/webwriter-order.css +0 -444
  86. package/dist/widgets/webwriter-pairing-item.css +0 -444
  87. package/dist/widgets/webwriter-pairing.css +0 -444
  88. package/dist/widgets/webwriter-quiz.css +0 -444
  89. package/dist/widgets/webwriter-task-explainer.css +0 -444
  90. package/dist/widgets/webwriter-task-explainer.js +0 -2872
  91. package/dist/widgets/webwriter-task-hint.css +0 -444
  92. package/dist/widgets/webwriter-task-hint.js +0 -2811
  93. package/dist/widgets/webwriter-task.css +0 -444
  94. package/dist/widgets/webwriter-text.css +0 -444
  95. package/localization/bg.xlf +0 -69
  96. package/localization/cs.xlf +0 -69
  97. package/localization/da.xlf +0 -69
  98. package/localization/el.xlf +0 -69
  99. package/localization/et.xlf +0 -69
  100. package/localization/fi.xlf +0 -69
  101. package/localization/generated/bg.ts +0 -30
  102. package/localization/generated/cs.ts +0 -30
  103. package/localization/generated/da.ts +0 -30
  104. package/localization/generated/el.ts +0 -30
  105. package/localization/generated/et.ts +0 -30
  106. package/localization/generated/fi.ts +0 -30
  107. package/localization/generated/hu.ts +0 -30
  108. package/localization/generated/id.ts +0 -30
  109. package/localization/generated/ja.ts +0 -30
  110. package/localization/generated/ko.ts +0 -30
  111. package/localization/generated/lt.ts +0 -30
  112. package/localization/generated/lv.ts +0 -30
  113. package/localization/generated/nb.ts +0 -30
  114. package/localization/generated/pl.ts +0 -30
  115. package/localization/generated/pt-BR.ts +0 -30
  116. package/localization/generated/pt-PT.ts +0 -30
  117. package/localization/generated/ro.ts +0 -30
  118. package/localization/generated/ru.ts +0 -30
  119. package/localization/generated/sk.ts +0 -30
  120. package/localization/generated/sl.ts +0 -30
  121. package/localization/generated/sv.ts +0 -30
  122. package/localization/generated/tr.ts +0 -30
  123. package/localization/generated/uk.ts +0 -30
  124. package/localization/generated/zh-hans.ts +0 -30
  125. package/localization/hu.xlf +0 -69
  126. package/localization/id.xlf +0 -69
  127. package/localization/ja.xlf +0 -69
  128. package/localization/ko.xlf +0 -69
  129. package/localization/lt.xlf +0 -69
  130. package/localization/lv.xlf +0 -69
  131. package/localization/nb.xlf +0 -69
  132. package/localization/pl.xlf +0 -69
  133. package/localization/pt-BR.xlf +0 -69
  134. package/localization/pt-PT.xlf +0 -69
  135. package/localization/ro.xlf +0 -69
  136. package/localization/ru.xlf +0 -69
  137. package/localization/sk.xlf +0 -69
  138. package/localization/sl.xlf +0 -69
  139. package/localization/sv.xlf +0 -69
  140. package/localization/tr.xlf +0 -69
  141. package/localization/uk.xlf +0 -69
  142. package/localization/zh-hans.xlf +0 -69
  143. package/src/lib/combobox.ts +0 -456
  144. package/src/lib/highlighter-fill.svg +0 -6
  145. package/src/snippets/Beispiel-Choice.html +0 -57
  146. package/src/snippets/Beispiel-Mark.html +0 -14
  147. package/src/snippets/Beispiel-Order.html +0 -52
  148. package/src/snippets/Beispiel-Text.html +0 -20
  149. package/src/snippets/blank.html +0 -1
  150. package/src/snippets/choice.html +0 -8
  151. package/src/snippets/cloze.html +0 -4
  152. package/src/snippets/mark.html +0 -4
  153. package/src/snippets/pairing.html +0 -10
  154. package/src/snippets/speech.html +0 -4
  155. package/src/snippets/text.html +0 -4
  156. package/src/snippets/wordsearch.html +0 -4
  157. package/src/widgets/webwriter-choice-item.ts +0 -254
  158. package/src/widgets/webwriter-choice.ts +0 -353
  159. package/src/widgets/webwriter-cloze-gap.ts +0 -216
  160. package/src/widgets/webwriter-cloze.ts +0 -136
  161. package/src/widgets/webwriter-order-item.ts +0 -441
  162. package/src/widgets/webwriter-order.ts +0 -327
  163. package/src/widgets/webwriter-pairing-item.ts +0 -161
  164. package/src/widgets/webwriter-pairing.ts +0 -193
  165. package/src/widgets/webwriter-speech.ts +0 -273
  166. package/src/widgets/webwriter-task-explainer.ts +0 -43
  167. package/src/widgets/webwriter-task-hint.ts +0 -23
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Migrates quiz content authored with `@webwriter/quiz` v1 to the v2 element
3
+ * set. WebWriter loads this script into an offscreen iframe before parsing a
4
+ * document and dispatches a `migrate` event on every outdated widget, innermost
5
+ * first, so each handler can assume its own children were already migrated.
6
+ *
7
+ * The dispatching document is snapshotted as soon as the iframe fires `load`,
8
+ * which means everything here has to run synchronously — see
9
+ * `./legacy-solution` for why the v1 solution crypto is reimplemented by
10
+ * hand instead of using `crypto.subtle`.
11
+ */
12
+
13
+ import { name as packageName, version as packageVersion } from "../../package.json";
14
+ import { encryptedProperty } from "../lib/encrypted-property";
15
+ import { decryptLegacySolution } from "./legacy-solution";
16
+
17
+ /** Documents written by this major version (or newer) are left alone. */
18
+ const CURRENT_MAJOR_VERSION = Number(packageVersion.split(".")[0]);
19
+
20
+ /** Tags that can act as the answer of a `<webwriter-task>` after migration. */
21
+ const ANSWER_TAGS = new Set([
22
+ "webwriter-choice",
23
+ "webwriter-true-false",
24
+ "webwriter-order",
25
+ "webwriter-text",
26
+ "webwriter-mark",
27
+ "webwriter-gap",
28
+ "webwriter-pairing",
29
+ "webwriter-speech",
30
+ ]);
31
+
32
+ /** Quizzes this script created to host tasks that used to stand on their own. */
33
+ const generatedQuizzes = new WeakSet<Element>();
34
+
35
+ /** Clozes whose gaps offered a list of options, which v2 models as drag and drop. */
36
+ const dragAndDropClozes = new WeakSet<Element>();
37
+
38
+ /** Decrypted v1 task solutions, since decryption costs ~150ms per task. */
39
+ const taskSolutions = new WeakMap<Element, unknown>();
40
+
41
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
42
+
43
+ function warn(message: string, ...details: unknown[]) {
44
+ console.warn(`[${packageName}] ${message}`, ...details);
45
+ }
46
+
47
+ function uniqueId() {
48
+ const uuid = crypto.randomUUID?.() ?? `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
49
+ return `ww-${uuid}`;
50
+ }
51
+
52
+ /** Marks `el` as belonging to the current version of this package. */
53
+ function markAsCurrent(el: Element) {
54
+ for (const cls of Array.from(el.classList)) {
55
+ if (cls.startsWith("ww-v")) el.classList.remove(cls);
56
+ }
57
+ el.classList.add("ww-widget", `ww-v${packageVersion}`, `ww-pkg-${packageName}`);
58
+ if (!el.id) el.id = uniqueId();
59
+ }
60
+
61
+ function createWidget(tag: string, id?: string) {
62
+ const el = document.createElement(tag);
63
+ if (id) el.id = id;
64
+ markAsCurrent(el);
65
+ return el;
66
+ }
67
+
68
+ /** Replaces `el` with an equivalent `tag` element, keeping its id, attributes and children. */
69
+ function retag(el: Element, tag: string) {
70
+ const replacement = document.createElement(tag);
71
+ for (const name of el.getAttributeNames()) replacement.setAttribute(name, el.getAttribute(name)!);
72
+ replacement.append(...Array.from(el.childNodes));
73
+ markAsCurrent(replacement);
74
+ el.replaceWith(replacement);
75
+ return replacement;
76
+ }
77
+
78
+ function removeAttributes(el: Element, ...names: string[]) {
79
+ for (const name of names) el.removeAttribute(name);
80
+ }
81
+
82
+ function renameAttribute(el: Element, from: string, to: string) {
83
+ if (!el.hasAttribute(from)) return;
84
+ el.setAttribute(to, el.getAttribute(from)!);
85
+ el.removeAttribute(from);
86
+ }
87
+
88
+ function parseJson(value: string | null): unknown {
89
+ if (!value) return undefined;
90
+ try {
91
+ return JSON.parse(value);
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Writes a solution the way v2 expects it. The value is obfuscated with the
99
+ * same converter the widgets use, so it never lands in the document as
100
+ * readable text.
101
+ */
102
+ function setSolution(el: Element, value: unknown) {
103
+ const attribute = encryptedProperty.toAttribute?.(value, Array);
104
+ if (typeof attribute === "string") el.setAttribute("solution", attribute);
105
+ }
106
+
107
+ /**
108
+ * Reads the solution of the `<webwriter-task>` containing `el`. v1 stored the
109
+ * answer element's solution on the task, encrypted with a hardcoded password.
110
+ */
111
+ function getTaskSolution(el: Element): unknown {
112
+ const task = el.closest("webwriter-task");
113
+ if (!task) return undefined;
114
+ if (taskSolutions.has(task)) return taskSolutions.get(task);
115
+
116
+ const solution = task.getAttribute("solution");
117
+ const iv = task.getAttribute("iv");
118
+ const salt = task.getAttribute("salt");
119
+ let value: unknown = undefined;
120
+
121
+ if (solution && iv && salt) {
122
+ try {
123
+ value = decryptLegacySolution(solution, iv, salt);
124
+ } catch (error) {
125
+ warn("Could not decrypt the solution of a task, it will have to be entered again.", task, error);
126
+ }
127
+ }
128
+
129
+ taskSolutions.set(task, value);
130
+ return value;
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Quiz, task and prompt
135
+ // ---------------------------------------------------------------------------
136
+
137
+ function migrateQuiz(el: Element) {
138
+ // v2 has neither randomized task order nor task counters.
139
+ removeAttributes(el, "randomorder", "counter", "submitted");
140
+ markAsCurrent(el);
141
+ }
142
+
143
+ /** Returns the task's prompt, creating one if the task somehow lost it. */
144
+ function ensurePrompt(task: Element) {
145
+ let prompt = task.querySelector(":scope > webwriter-task-prompt");
146
+ if (!prompt) {
147
+ prompt = createWidget("webwriter-task-prompt");
148
+ prompt.appendChild(document.createElement("p"));
149
+ task.insertBefore(prompt, task.firstChild);
150
+ }
151
+ prompt.setAttribute("slot", "prompt");
152
+ return prompt;
153
+ }
154
+
155
+ /**
156
+ * v1 tasks carried their own submit button, so they were usable on their own.
157
+ * In v2 grading lives on the quiz, so standalone tasks need a quiz around them.
158
+ * Consecutive tasks are collected into a single quiz.
159
+ */
160
+ function ensureQuiz(task: Element) {
161
+ const parent = task.parentElement;
162
+ if (!parent || parent.localName === "webwriter-quiz") return;
163
+
164
+ const previous = task.previousElementSibling;
165
+ if (previous && generatedQuizzes.has(previous)) {
166
+ previous.appendChild(task);
167
+ return;
168
+ }
169
+
170
+ const quiz = createWidget("webwriter-quiz");
171
+ generatedQuizzes.add(quiz);
172
+ parent.insertBefore(quiz, task);
173
+ quiz.appendChild(task);
174
+ }
175
+
176
+ function migrateTask(el: Element) {
177
+ const solution = getTaskSolution(el);
178
+ removeAttributes(el, "solution", "iv", "salt", "hint", "submitted", "counter");
179
+ applyAnswerSolution(el, solution);
180
+ ensurePrompt(el);
181
+ markAsCurrent(el);
182
+ ensureQuiz(el);
183
+ }
184
+
185
+ function migratePrompt(el: Element) {
186
+ el.setAttribute("slot", "prompt");
187
+ markAsCurrent(el);
188
+ }
189
+
190
+ /** Elements that cannot survive inside the `p+` content of a hint. */
191
+ const BLOCK_TAGS = new Set(["div", "section", "article", "ul", "ol", "li", "blockquote", "pre", "figure"]);
192
+
193
+ /**
194
+ * Appends `nodes` to `target` as paragraphs, since `<webwriter-quiz-hint>`
195
+ * accepts `p+` while v1 hints and explainers accepted arbitrary flow content.
196
+ */
197
+ function appendAsParagraphs(target: Element, nodes: Node[]) {
198
+ let paragraph: HTMLParagraphElement | undefined;
199
+
200
+ for (const node of nodes) {
201
+ const tag = node.nodeType === Node.ELEMENT_NODE ? (node as Element).localName : undefined;
202
+
203
+ if (tag === "p") {
204
+ target.appendChild(node);
205
+ paragraph = undefined;
206
+ } else if (tag && (BLOCK_TAGS.has(tag) || /^h[1-6]$/.test(tag))) {
207
+ const replacement = document.createElement("p");
208
+ replacement.append(...Array.from(node.childNodes));
209
+ target.appendChild(replacement);
210
+ paragraph = undefined;
211
+ } else if (node.nodeType !== Node.TEXT_NODE || node.textContent?.trim()) {
212
+ if (!paragraph) {
213
+ paragraph = document.createElement("p");
214
+ target.appendChild(paragraph);
215
+ }
216
+ paragraph.appendChild(node);
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Returns the task's single `<webwriter-quiz-hint>`, creating it if needed. The
223
+ * prompt slots it by name and expects it first, see `webwriter-task-prompt` in
224
+ * `editing-config.json` and `hint-popup`.
225
+ */
226
+ function ensureHint(task: Element, id?: string) {
227
+ const prompt = ensurePrompt(task);
228
+ let hint = prompt.querySelector(":scope > webwriter-quiz-hint");
229
+
230
+ if (!hint) {
231
+ hint = createWidget("webwriter-quiz-hint", id);
232
+ hint.setAttribute("slot", "hint");
233
+ prompt.prepend(hint);
234
+ }
235
+
236
+ return hint;
237
+ }
238
+
239
+ /**
240
+ * Folds a v1 `<webwriter-task-hint>` or `<webwriter-task-explainer>` into the
241
+ * task's hint. v1 allowed one hint plus any number of explainers per task,
242
+ * while v2 has a single hint, so their contents are concatenated in document
243
+ * order.
244
+ */
245
+ function foldIntoHint(el: Element, id?: string) {
246
+ const task = el.closest("webwriter-task");
247
+
248
+ if (task) {
249
+ appendAsParagraphs(ensureHint(task, id), Array.from(el.childNodes));
250
+ } else {
251
+ warn("Dropped a hint that was not inside a task.", el);
252
+ }
253
+
254
+ el.remove();
255
+ }
256
+
257
+ function migrateHint(el: Element) {
258
+ foldIntoHint(el, el.id);
259
+ }
260
+
261
+ /**
262
+ * v1 showed explainers as tabs after submitting. v2 has no such element, so
263
+ * their content is kept as part of the task's hint.
264
+ */
265
+ function migrateExplainer(el: Element) {
266
+ warn("A task explainer became part of the task's hint, since v2 has no explainers.", el);
267
+ foldIntoHint(el);
268
+ }
269
+
270
+ /**
271
+ * Applies a decrypted v1 solution to the already migrated answer element. Gaps,
272
+ * marks and pairings keep their solution on the answer itself and are handled
273
+ * by their own migrations.
274
+ */
275
+ function applyAnswerSolution(task: Element, solution: unknown) {
276
+ const answer = Array.from(task.children).find(child => ANSWER_TAGS.has(child.localName));
277
+ if (!answer || solution === undefined || solution === null) return;
278
+
279
+ if (answer.localName === "webwriter-choice" && Array.isArray(solution)) {
280
+ // v1 listed the ids of the correct options, v2 maps every option to a boolean.
281
+ const value: Record<string, boolean> = {};
282
+ for (const item of answer.querySelectorAll(":scope > webwriter-choice-item")) {
283
+ value[item.id] = solution.includes(item.id);
284
+ }
285
+ setSolution(answer, value);
286
+ } else if (answer.localName === "webwriter-order" && Array.isArray(solution)) {
287
+ const items = Array.from(answer.querySelectorAll(":scope > webwriter-order-item"));
288
+ const ordered = solution
289
+ .map(id => items.find(item => item.id === id))
290
+ .filter((item): item is Element => item !== undefined);
291
+ const rest = items.filter(item => !ordered.includes(item));
292
+
293
+ // v2 takes the authoring order as the solution, so sort the DOM instead.
294
+ const sorted = [...ordered, ...rest];
295
+ for (const item of sorted) answer.appendChild(item);
296
+ setSolution(
297
+ answer,
298
+ sorted.map(item => item.id),
299
+ );
300
+ } else if (answer.localName === "webwriter-text" && typeof solution === "string") {
301
+ setSolution(answer, solution);
302
+ } else if (answer.localName === "webwriter-pairing" && !answer.hasAttribute("solution")) {
303
+ applyPairingSolution(answer, Array.isArray(solution) ? solution : []);
304
+ }
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Choice and order
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /** v1 kept the layout on each item, v2 keeps it on the container. */
312
+ function hoistLayout(el: Element, containerTag: string) {
313
+ const layout = el.getAttribute("layout");
314
+ const container = el.parentElement;
315
+ if (layout && container?.localName === containerTag && !container.hasAttribute("layout")) {
316
+ container.setAttribute("layout", layout);
317
+ }
318
+ }
319
+
320
+ function migrateChoice(el: Element) {
321
+ const mode = el.getAttribute("mode") ?? "single";
322
+
323
+ if (mode === "truefalse") {
324
+ // v1 deleted all options in this mode and never stored which side was
325
+ // correct, so the author has to pick it again.
326
+ const trueFalse = createWidget("webwriter-true-false", el.id);
327
+ trueFalse.setAttribute("solution", "true");
328
+ const discarded = el.querySelectorAll(":scope > webwriter-choice-item").length;
329
+ el.replaceWith(trueFalse);
330
+ warn(
331
+ `A true/false question lost its solution, since v1 never stored one.${discarded ? ` ${discarded} leftover option(s) were dropped.` : ""}`,
332
+ trueFalse,
333
+ );
334
+ return;
335
+ }
336
+
337
+ el.setAttribute("mode", mode === "multiple" ? "multiple" : "single");
338
+ if (el.getAttribute("layout") !== "tiles") el.removeAttribute("layout");
339
+ if (el.hasAttribute("randomorder")) el.setAttribute("randomize-order", "");
340
+ // v1's `showsolution` is covered by the quiz-wide "detailed feedback" option.
341
+ removeAttributes(el, "randomorder", "showsolution");
342
+
343
+ if (!el.querySelector(":scope > webwriter-choice-item")) {
344
+ const item = createWidget("webwriter-choice-item");
345
+ item.appendChild(document.createElement("p"));
346
+ el.appendChild(item);
347
+ }
348
+
349
+ markAsCurrent(el);
350
+ }
351
+
352
+ function migrateChoiceItem(el: Element) {
353
+ hoistLayout(el, "webwriter-choice");
354
+ removeAttributes(el, "layout");
355
+ markAsCurrent(el);
356
+ }
357
+
358
+ function migrateOrder(el: Element) {
359
+ if (el.getAttribute("layout") !== "tiles") el.removeAttribute("layout");
360
+ removeAttributes(el, "hideorderbuttons", "showsolution");
361
+ markAsCurrent(el);
362
+ }
363
+
364
+ function migrateOrderItem(el: Element) {
365
+ hoistLayout(el, "webwriter-order");
366
+ removeAttributes(el, "layout", "draggable", "droppreview", "hideorderbuttons");
367
+ markAsCurrent(el);
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Text, speech and mark
372
+ // ---------------------------------------------------------------------------
373
+
374
+ function migrateText(el: Element) {
375
+ renameAttribute(el, "freetext", "free-text");
376
+ renameAttribute(el, "ignorecase", "ignore-case");
377
+ renameAttribute(el, "wrongmessage", "wrong-message");
378
+ // `showsolution` is now the quiz-wide "detailed feedback" option, `value`
379
+ // held a learner's answer.
380
+ removeAttributes(el, "showsolution", "value");
381
+ markAsCurrent(el);
382
+ }
383
+
384
+ function migrateSpeech(el: Element) {
385
+ removeAttributes(el, "loading", "recording");
386
+ markAsCurrent(el);
387
+ }
388
+
389
+ type LegacyRange = { startContainer: string; startOffset: number; endContainer?: string; endOffset: number };
390
+
391
+ function isLegacyRange(value: unknown): value is LegacyRange {
392
+ const range = value as LegacyRange;
393
+ return typeof range?.startContainer === "string" && typeof range?.startOffset === "number";
394
+ }
395
+
396
+ function evaluateXPath(path: string) {
397
+ try {
398
+ return document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ /** Resolves a v1 range, which addressed its boundaries by document-wide XPath. */
405
+ function resolveLegacyRange(legacy: LegacyRange, scope: Element) {
406
+ const start = evaluateXPath(legacy.startContainer);
407
+ const end = legacy.endContainer ? evaluateXPath(legacy.endContainer) : start;
408
+ if (!start || !end || !scope.contains(start) || !scope.contains(end)) return undefined;
409
+
410
+ const range = document.createRange();
411
+ const clamp = (node: Node, offset: number) =>
412
+ Math.max(
413
+ 0,
414
+ Math.min(offset, node.nodeType === Node.TEXT_NODE ? (node as Text).data.length : node.childNodes.length),
415
+ );
416
+ range.setStart(start, clamp(start, legacy.startOffset));
417
+ range.setEnd(end, clamp(end, legacy.endOffset));
418
+ return range;
419
+ }
420
+
421
+ /** The word-like segments of `root`, indexed exactly like `<webwriter-mark>` does. */
422
+ function wordSegments(root: Element) {
423
+ const segments: Range[] = [];
424
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
425
+
426
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
427
+ const text = node as Text;
428
+ for (const segment of segmenter.segment(text.data)) {
429
+ if (!segment.isWordLike) continue;
430
+ const range = document.createRange();
431
+ range.setStart(text, segment.index);
432
+ range.setEnd(text, segment.index + segment.segment.length);
433
+ segments.push(range);
434
+ }
435
+ }
436
+
437
+ return segments;
438
+ }
439
+
440
+ function rangesOverlap(a: Range, b: Range) {
441
+ return a.compareBoundaryPoints(Range.END_TO_START, b) < 0 && a.compareBoundaryPoints(Range.START_TO_END, b) > 0;
442
+ }
443
+
444
+ /**
445
+ * v1 stored marked text as XPath ranges on the task, v2 stores the indices of
446
+ * the marked words. The ranges are resolved before the content is flattened to
447
+ * plain text, since the XPaths describe the document as it was saved.
448
+ */
449
+ function migrateMark(el: Element) {
450
+ const legacy = (Array.isArray(getTaskSolution(el)) ? getTaskSolution(el) : []) as unknown[];
451
+ const ranges = legacy
452
+ .filter(isLegacyRange)
453
+ .map(value => resolveLegacyRange(value, el))
454
+ .filter((range): range is Range => range !== undefined);
455
+
456
+ if (legacy.length && !ranges.length) {
457
+ // The XPaths were resolved against the document as it was saved, so they
458
+ // can go stale if the surrounding content moved.
459
+ warn("Could not locate the marked words of a task, they have to be marked again.", el);
460
+ }
461
+
462
+ // v2's content model is text only. Line breaks become newlines so that the
463
+ // words around them stay separate segments.
464
+ for (const child of Array.from(el.querySelectorAll("br, wbr"))) {
465
+ if (child.localName === "br") child.replaceWith(document.createTextNode("\n"));
466
+ else child.remove();
467
+ }
468
+ for (let child = el.firstElementChild; child; child = el.firstElementChild) {
469
+ child.replaceWith(...Array.from(child.childNodes));
470
+ }
471
+
472
+ const segments = wordSegments(el);
473
+ const solution = segments
474
+ .map((segment, index) => (ranges.some(range => rangesOverlap(range, segment)) ? index : -1))
475
+ .filter(index => index !== -1);
476
+
477
+ // `highlighting` was an authoring toggle, `value` held a learner's marks.
478
+ removeAttributes(el, "highlighting", "value");
479
+ if (solution.length) setSolution(el, solution);
480
+ markAsCurrent(el);
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Cloze (now gap)
485
+ // ---------------------------------------------------------------------------
486
+
487
+ function migrateCloze(el: Element) {
488
+ const dragAndDrop = dragAndDropClozes.has(el);
489
+ const gap = retag(el, "webwriter-gap");
490
+ if (dragAndDrop) gap.setAttribute("mode", "drag-and-drop");
491
+ }
492
+
493
+ function migrateClozeGap(el: Element) {
494
+ if (el.hasAttribute("showoptions")) {
495
+ const cloze = el.closest("webwriter-cloze");
496
+ if (cloze) dragAndDropClozes.add(cloze);
497
+ }
498
+
499
+ const solutions = parseJson(el.getAttribute("solution"));
500
+ const accepted = Array.isArray(solutions) ? solutions.filter(value => typeof value === "string") : [];
501
+ if (accepted.length > 1) {
502
+ warn(`A gap accepted ${accepted.length} answers, v2 keeps only "${accepted[0]}".`, el);
503
+ }
504
+ if (parseJson(el.getAttribute("distraction"))) {
505
+ warn("A gap had distractor options, which v2 does not support. They were dropped.", el);
506
+ }
507
+
508
+ const item = createWidget("webwriter-gap-item", el.id);
509
+ item.textContent = accepted[0] ?? "";
510
+ el.replaceWith(item);
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // Pairing
515
+ // ---------------------------------------------------------------------------
516
+
517
+ /** Keeps only complete pairs, which are the only thing v2 can render. */
518
+ function applyPairingSolution(el: Element, legacy: unknown[]) {
519
+ const items = Array.from(el.querySelectorAll(":scope > webwriter-pairing-item"));
520
+ const ids = new Set(items.map(item => item.id));
521
+ const pairs = legacy.filter(
522
+ (entry): entry is [string, string] =>
523
+ Array.isArray(entry) && entry.length === 2 && entry.every(id => typeof id === "string" && ids.has(id)),
524
+ );
525
+ const paired = new Set(pairs.flat());
526
+
527
+ for (const item of items) {
528
+ if (paired.has(item.id)) continue;
529
+ warn("Removed a pairing item that was not part of a pair, which v2 cannot represent.", item);
530
+ item.remove();
531
+ }
532
+
533
+ setSolution(el, pairs);
534
+ }
535
+
536
+ function migratePairing(el: Element) {
537
+ el.setAttribute("mode", el.getAttribute("mode") === "memory" ? "memory" : "pairing");
538
+ const legacy = parseJson(el.getAttribute("solution")) ?? getTaskSolution(el);
539
+ applyPairingSolution(el, Array.isArray(legacy) ? legacy : []);
540
+ markAsCurrent(el);
541
+ }
542
+
543
+ function migratePairingItem(el: Element) {
544
+ removeAttributes(el, "draggable", "droppreview");
545
+
546
+ // v2 allows a single `<p>` or `<picture>`, v1 allowed any flow content.
547
+ const picture = el.querySelector("picture");
548
+ if (picture) {
549
+ el.replaceChildren(picture);
550
+ } else {
551
+ const paragraph = document.createElement("p");
552
+ paragraph.append(...Array.from(el.childNodes));
553
+ for (const child of Array.from(paragraph.children)) {
554
+ if (child.localName === "p" || child.localName === "div") {
555
+ child.replaceWith(...Array.from(child.childNodes));
556
+ }
557
+ }
558
+ el.replaceChildren(paragraph);
559
+ }
560
+
561
+ markAsCurrent(el);
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Entry point
566
+ // ---------------------------------------------------------------------------
567
+
568
+ function migrateElement(el: Element) {
569
+ switch (el.localName) {
570
+ case "webwriter-quiz":
571
+ return migrateQuiz(el);
572
+ case "webwriter-task":
573
+ return migrateTask(el);
574
+ case "webwriter-task-prompt":
575
+ return migratePrompt(el);
576
+ case "webwriter-task-hint":
577
+ return migrateHint(el);
578
+ case "webwriter-task-explainer":
579
+ return migrateExplainer(el);
580
+ case "webwriter-choice":
581
+ return migrateChoice(el);
582
+ case "webwriter-choice-item":
583
+ return migrateChoiceItem(el);
584
+ case "webwriter-order":
585
+ return migrateOrder(el);
586
+ case "webwriter-order-item":
587
+ return migrateOrderItem(el);
588
+ case "webwriter-text":
589
+ return migrateText(el);
590
+ case "webwriter-speech":
591
+ return migrateSpeech(el);
592
+ case "webwriter-mark":
593
+ return migrateMark(el);
594
+ case "webwriter-cloze":
595
+ return migrateCloze(el);
596
+ case "webwriter-cloze-gap":
597
+ return migrateClozeGap(el);
598
+ case "webwriter-pairing":
599
+ return migratePairing(el);
600
+ case "webwriter-pairing-item":
601
+ return migratePairingItem(el);
602
+ default:
603
+ return markAsCurrent(el);
604
+ }
605
+ }
606
+
607
+ document.addEventListener("migrate", event => {
608
+ const el = event.target as Element | null;
609
+ if (!el?.classList) return;
610
+
611
+ const classes = Array.from(el.classList);
612
+ if (classes.find(cls => cls.startsWith("ww-pkg-"))?.slice("ww-pkg-".length) !== packageName) return;
613
+
614
+ const major = Number.parseInt(classes.find(cls => cls.startsWith("ww-v"))?.slice("ww-v".length) ?? "", 10);
615
+ if (!Number.isFinite(major) || major >= CURRENT_MAJOR_VERSION) return;
616
+
617
+ try {
618
+ migrateElement(el);
619
+ } catch (error) {
620
+ warn(`Could not migrate a <${el.localName}>, it may display incorrectly.`, el, error);
621
+ }
622
+ });