@webwriter/quiz 1.1.5 → 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 (165) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +778 -0
  3. package/assets/icons/grip-horizontal.svg +8 -0
  4. package/assets/icons/grip-vertical.svg +8 -0
  5. package/assets/icons/truefalse.svg +6 -0
  6. package/custom-elements.json +2779 -0
  7. package/custom.d.ts +5 -1
  8. package/dist/api/index.d.ts +27 -0
  9. package/dist/api/index.js +40 -0
  10. package/dist/migrate.js +1434 -0
  11. package/dist/widgets/webwriter-choice-item.js +4095 -3818
  12. package/dist/widgets/webwriter-choice.js +2179 -5598
  13. package/dist/widgets/webwriter-gap-item.js +3987 -0
  14. package/dist/widgets/webwriter-gap.js +2908 -0
  15. package/dist/widgets/webwriter-mark.js +4347 -3368
  16. package/dist/widgets/webwriter-order-item.js +4038 -3197
  17. package/dist/widgets/webwriter-order.js +2885 -4200
  18. package/dist/widgets/webwriter-pairing-item.js +1070 -2330
  19. package/dist/widgets/webwriter-pairing.js +2968 -4231
  20. package/dist/widgets/webwriter-quiz-hint.js +2469 -0
  21. package/dist/widgets/webwriter-quiz.js +3337 -4364
  22. package/dist/widgets/webwriter-speech.js +6100 -5120
  23. package/dist/widgets/webwriter-task-prompt.js +4787 -2068
  24. package/dist/widgets/webwriter-task.js +2264 -8157
  25. package/dist/widgets/webwriter-text.js +4075 -4132
  26. package/dist/widgets/webwriter-true-false.js +4674 -0
  27. package/editing-config.json +106 -0
  28. package/lit-localize.json +20 -14
  29. package/localization/de.xlf +395 -65
  30. package/localization/es.xlf +396 -61
  31. package/localization/fr.xlf +396 -61
  32. package/localization/generated/de.ts +97 -15
  33. package/localization/generated/es.ts +97 -15
  34. package/localization/generated/fr.ts +98 -16
  35. package/localization/generated/index.d.ts +5 -0
  36. package/localization/generated/it.ts +96 -14
  37. package/localization/generated/locale-codes.js +0 -48
  38. package/localization/generated/nl.ts +99 -17
  39. package/localization/it.xlf +396 -61
  40. package/localization/nl.xlf +396 -61
  41. package/package.json +120 -254
  42. package/scripts/apply-translations.mjs +97 -0
  43. package/src/api/index.ts +72 -0
  44. package/src/lib/artboard.ts +686 -0
  45. package/src/lib/draggable-grid.ts +432 -0
  46. package/src/lib/encrypted-property.ts +202 -0
  47. package/src/lib/hint-popup.ts +152 -0
  48. package/src/lib/utils.ts +97 -0
  49. package/src/lib/webwriter-quirks.ts +121 -0
  50. package/src/migrate/index.ts +622 -0
  51. package/src/migrate/legacy-solution.ts +362 -0
  52. package/src/snippets/order.html +8 -7
  53. package/src/widgets/choice/webwriter-choice-item.ts +316 -0
  54. package/src/widgets/choice/webwriter-choice.ts +328 -0
  55. package/src/widgets/choice/webwriter-true-false.ts +113 -0
  56. package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
  57. package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
  58. package/src/widgets/dnd/webwriter-dnd.ts +36 -0
  59. package/src/widgets/gap/webwriter-gap-item.ts +250 -0
  60. package/src/widgets/gap/webwriter-gap.ts +447 -0
  61. package/src/widgets/order/webwriter-order-item.ts +243 -0
  62. package/src/widgets/order/webwriter-order.ts +282 -0
  63. package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
  64. package/src/widgets/pairing/webwriter-pairing.ts +934 -0
  65. package/src/widgets/speech/audio-player.ts +325 -0
  66. package/src/widgets/speech/audio-recorder.ts +285 -0
  67. package/src/widgets/speech/waveform.ts +113 -0
  68. package/src/widgets/speech/webwriter-speech.ts +87 -0
  69. package/src/widgets/webwriter-mark.ts +366 -424
  70. package/src/widgets/webwriter-quiz-hint.ts +36 -0
  71. package/src/widgets/webwriter-quiz.ts +318 -280
  72. package/src/widgets/webwriter-task-prompt.ts +43 -18
  73. package/src/widgets/webwriter-task.ts +212 -551
  74. package/src/widgets/webwriter-text.ts +286 -172
  75. package/tsconfig.api.json +12 -0
  76. package/tsconfig.json +7 -6
  77. package/dist/widgets/webwriter-choice.css +0 -444
  78. package/dist/widgets/webwriter-cloze-gap.css +0 -444
  79. package/dist/widgets/webwriter-cloze-gap.js +0 -5727
  80. package/dist/widgets/webwriter-cloze.css +0 -444
  81. package/dist/widgets/webwriter-cloze.js +0 -3653
  82. package/dist/widgets/webwriter-mark.css +0 -444
  83. package/dist/widgets/webwriter-order.css +0 -444
  84. package/dist/widgets/webwriter-pairing-item.css +0 -444
  85. package/dist/widgets/webwriter-pairing.css +0 -444
  86. package/dist/widgets/webwriter-quiz.css +0 -444
  87. package/dist/widgets/webwriter-task-explainer.css +0 -444
  88. package/dist/widgets/webwriter-task-explainer.js +0 -2769
  89. package/dist/widgets/webwriter-task-hint.css +0 -444
  90. package/dist/widgets/webwriter-task-hint.js +0 -2708
  91. package/dist/widgets/webwriter-task.css +0 -444
  92. package/dist/widgets/webwriter-text.css +0 -444
  93. package/localization/bg.xlf +0 -72
  94. package/localization/cs.xlf +0 -72
  95. package/localization/da.xlf +0 -72
  96. package/localization/el.xlf +0 -72
  97. package/localization/et.xlf +0 -72
  98. package/localization/fi.xlf +0 -72
  99. package/localization/generated/bg.ts +0 -31
  100. package/localization/generated/cs.ts +0 -31
  101. package/localization/generated/da.ts +0 -31
  102. package/localization/generated/el.ts +0 -31
  103. package/localization/generated/et.ts +0 -31
  104. package/localization/generated/fi.ts +0 -31
  105. package/localization/generated/hu.ts +0 -31
  106. package/localization/generated/id.ts +0 -31
  107. package/localization/generated/ja.ts +0 -31
  108. package/localization/generated/ko.ts +0 -31
  109. package/localization/generated/lt.ts +0 -31
  110. package/localization/generated/lv.ts +0 -31
  111. package/localization/generated/nb.ts +0 -31
  112. package/localization/generated/pl.ts +0 -31
  113. package/localization/generated/pt-BR.ts +0 -31
  114. package/localization/generated/pt-PT.ts +0 -31
  115. package/localization/generated/ro.ts +0 -31
  116. package/localization/generated/ru.ts +0 -31
  117. package/localization/generated/sk.ts +0 -31
  118. package/localization/generated/sl.ts +0 -31
  119. package/localization/generated/sv.ts +0 -31
  120. package/localization/generated/tr.ts +0 -31
  121. package/localization/generated/uk.ts +0 -31
  122. package/localization/generated/zh-hans.ts +0 -31
  123. package/localization/hu.xlf +0 -72
  124. package/localization/id.xlf +0 -72
  125. package/localization/ja.xlf +0 -72
  126. package/localization/ko.xlf +0 -72
  127. package/localization/lt.xlf +0 -72
  128. package/localization/lv.xlf +0 -72
  129. package/localization/nb.xlf +0 -72
  130. package/localization/pl.xlf +0 -72
  131. package/localization/pt-BR.xlf +0 -72
  132. package/localization/pt-PT.xlf +0 -72
  133. package/localization/ro.xlf +0 -72
  134. package/localization/ru.xlf +0 -72
  135. package/localization/sk.xlf +0 -72
  136. package/localization/sl.xlf +0 -72
  137. package/localization/sv.xlf +0 -72
  138. package/localization/tr.xlf +0 -72
  139. package/localization/uk.xlf +0 -72
  140. package/localization/zh-hans.xlf +0 -72
  141. package/src/lib/combobox.ts +0 -456
  142. package/src/lib/highlighter-fill.svg +0 -6
  143. package/src/snippets/Beispiel-Choice.html +0 -57
  144. package/src/snippets/Beispiel-Mark.html +0 -14
  145. package/src/snippets/Beispiel-Order.html +0 -52
  146. package/src/snippets/Beispiel-Text.html +0 -20
  147. package/src/snippets/blank.html +0 -1
  148. package/src/snippets/choice.html +0 -8
  149. package/src/snippets/cloze.html +0 -4
  150. package/src/snippets/mark.html +0 -4
  151. package/src/snippets/pairing.html +0 -10
  152. package/src/snippets/speech.html +0 -4
  153. package/src/snippets/text.html +0 -4
  154. package/src/snippets/wordsearch.html +0 -4
  155. package/src/widgets/webwriter-choice-item.ts +0 -257
  156. package/src/widgets/webwriter-choice.ts +0 -353
  157. package/src/widgets/webwriter-cloze-gap.ts +0 -216
  158. package/src/widgets/webwriter-cloze.ts +0 -136
  159. package/src/widgets/webwriter-order-item.ts +0 -441
  160. package/src/widgets/webwriter-order.ts +0 -327
  161. package/src/widgets/webwriter-pairing-item.ts +0 -161
  162. package/src/widgets/webwriter-pairing.ts +0 -193
  163. package/src/widgets/webwriter-speech.ts +0 -273
  164. package/src/widgets/webwriter-task-explainer.ts +0 -43
  165. package/src/widgets/webwriter-task-hint.ts +0 -23
@@ -0,0 +1,934 @@
1
+ import { msg } from "@lit/localize";
2
+ import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js";
3
+ import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js";
4
+ import { ActionDeclaration, LitElementWw, OptionDeclaration } from "@webwriter/lit";
5
+ import PlusIcon from "bootstrap-icons/icons/plus.svg";
6
+ import ShuffleIcon from "bootstrap-icons/icons/shuffle.svg";
7
+ import TrashIcon from "bootstrap-icons/icons/trash.svg";
8
+ import { css, html, nothing, PropertyValues } from "lit";
9
+ import { customElement, property, queryAll, state } from "lit/decorators.js";
10
+ import { classMap } from "lit/directives/class-map.js";
11
+ import GripHorizontalIcon from "../../../assets/icons/grip-horizontal.svg";
12
+ import { name as packageId } from "../../../package.json";
13
+ import { IWebWriterQuizType, registerQuizType } from "../../api";
14
+ import { DraggableGrid, DraggableGridReorderEvent } from "../../lib/draggable-grid";
15
+ import { encryptedProperty, encryptPlaintextAttributes } from "../../lib/encrypted-property";
16
+ import { BiMap, randomizeArray } from "../../lib/utils";
17
+
18
+ declare global {
19
+ interface HTMLElementTagNameMap {
20
+ "webwriter-pairing": WebwriterPairing;
21
+ }
22
+ }
23
+
24
+ export type PairingMode = "pairing" | "memory";
25
+
26
+ type PairingQuizState = {
27
+ leftItemIds: string[];
28
+ rightItemIds: string[];
29
+ assignments: BiMap<string, string>;
30
+ };
31
+
32
+ type PairingDragState = {
33
+ element: HTMLElement;
34
+ cardId: string;
35
+ pointerId: number;
36
+ /** Transform placing the card where it was picked up; its scale grows to full size. */
37
+ base: { x: number; y: number; scale: number };
38
+ /** The assigned cell the card was taken out of, which shows its partner full size meanwhile. */
39
+ sourceCell: HTMLElement | null;
40
+ startPageX: number;
41
+ startPageY: number;
42
+ clientX: number;
43
+ clientY: number;
44
+ startTime: number;
45
+ lastFrameTime: number;
46
+ frame: number | null;
47
+ highlight: HTMLElement | null;
48
+ maxPageScrollY: number;
49
+ };
50
+
51
+ type MemoryGameState = {
52
+ itemIds: string[];
53
+ flippedItemIds: string[];
54
+ matchedItemIds: string[];
55
+ };
56
+
57
+ registerQuizType({
58
+ packageId,
59
+ widgetPosition: 40,
60
+ id: "webwriter-pairing",
61
+ getName: () => msg("Pairing"),
62
+ icon: ShuffleIcon,
63
+ createInstance: () => document.createElement("webwriter-pairing"),
64
+ });
65
+
66
+ const AUTO_SCROLL_EDGE_SIZE = 80; // px
67
+ const AUTO_SCROLL_SPEED = 1000; // px/s
68
+
69
+ const PAIRING_LIFT_MS = 140;
70
+ const PAIRING_SETTLE_MS = 260;
71
+ const PAIRING_SETTLE_EASING = "cubic-bezier(0.2, 0, 0, 1)";
72
+
73
+ function easeOut(progress: number) {
74
+ return 1 - Math.pow(1 - progress, 3);
75
+ }
76
+
77
+ function prefersReducedMotion() {
78
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
79
+ }
80
+
81
+ /**
82
+ * The `transform` that renders `element` (which must not have one) at `from`. Cards are sized
83
+ * by CSS `scale`, which applies before `transform` and thus scales its translation, and around
84
+ * a `transform-origin` that shifts the box whenever the scale changes: both are compensated.
85
+ */
86
+ function transformTo(element: HTMLElement, from: DOMRect) {
87
+ const current = element.getBoundingClientRect();
88
+ if (!current.width || !element.offsetWidth) return null;
89
+
90
+ const cssScale = current.width / element.offsetWidth;
91
+ const scale = from.width / current.width;
92
+ const [originX, originY] = window.getComputedStyle(element).transformOrigin.split(" ").map(parseFloat);
93
+
94
+ return {
95
+ x: (from.x - current.x) / cssScale + originX * (scale - 1),
96
+ y: (from.y - current.y) / cssScale + originY * (scale - 1),
97
+ scale,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * An answer where learners match items into pairs.
103
+ *
104
+ * The items must be `<webwriter-pairing-item>` children of this element.
105
+ */
106
+ @customElement("webwriter-pairing")
107
+ export class WebwriterPairing extends LitElementWw implements IWebWriterQuizType {
108
+ /** @internal */
109
+ static shadowRootOptions = {
110
+ ...LitElementWw.shadowRootOptions,
111
+ slotAssignment: "manual" as const,
112
+ };
113
+
114
+ /** @internal */
115
+ static scopedElements = {
116
+ "draggable-grid": DraggableGrid,
117
+ "sl-icon": SlIcon,
118
+ "sl-icon-button": SlIconButton,
119
+ };
120
+
121
+ static styles = css`
122
+ :host {
123
+ display: block;
124
+ --grid-gap: var(--sl-spacing-medium);
125
+ --item-min-width: 120px;
126
+ --pair-min-width: calc(var(--item-min-width) * 2 + 3px); /* incl. 2*border + divider */
127
+ outline: none !important;
128
+ }
129
+
130
+ .pairs {
131
+ display: grid;
132
+ grid-template-columns: repeat(auto-fill, minmax(var(--pair-min-width), 1fr));
133
+ gap: var(--grid-gap);
134
+ }
135
+
136
+ .pair {
137
+ position: relative;
138
+
139
+ .pair-content {
140
+ display: grid;
141
+ grid-template-columns: 1fr 1px 1fr;
142
+ box-sizing: border-box;
143
+
144
+ border: 1px solid var(--sl-color-neutral-300);
145
+ border-radius: var(--sl-border-radius-medium);
146
+ background-color: var(--sl-color-neutral-0);
147
+ overflow: hidden;
148
+ }
149
+
150
+ .actions {
151
+ height: 1lh;
152
+ display: flex;
153
+ align-items: center;
154
+ position: absolute;
155
+ background: var(--sl-color-neutral-0);
156
+ border: 1px solid var(--sl-input-border-color);
157
+ border-radius: var(--sl-border-radius-pill);
158
+ top: 0;
159
+ left: 50%;
160
+ translate: -50% -50%;
161
+ padding: 0 var(--sl-spacing-x-small);
162
+ box-sizing: border-box;
163
+ z-index: 100;
164
+
165
+ opacity: 0;
166
+
167
+ &:hover,
168
+ .pair:hover &,
169
+ .pair.ww-dragging & {
170
+ opacity: 1;
171
+ }
172
+ }
173
+ }
174
+
175
+ .divider {
176
+ height: 100%;
177
+ width: 100%;
178
+ background-color: var(--sl-color-neutral-300);
179
+ }
180
+
181
+ #add-pair {
182
+ display: grid;
183
+ grid-template-columns: 1fr 1px 1fr;
184
+ position: relative;
185
+ place-items: center;
186
+
187
+ /* Match styling of Shoelace button */
188
+ border: 1px dashed var(--sl-color-neutral-300);
189
+ border-radius: var(--sl-border-radius-medium);
190
+ box-sizing: border-box;
191
+ color: var(--sl-color-neutral-500);
192
+ cursor: pointer;
193
+ &:hover {
194
+ background-color: var(--sl-color-primary-50);
195
+ border-color: var(--sl-color-primary-300);
196
+ color: var(--sl-color-primary-700);
197
+ }
198
+
199
+ .label {
200
+ position: absolute;
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 0.5rem;
204
+ color: var(--sl-color-neutral-500);
205
+ }
206
+
207
+ .size-placeholder {
208
+ aspect-ratio: 1 / 1;
209
+ width: 100%;
210
+ }
211
+ }
212
+
213
+ .pairing-container {
214
+ display: grid;
215
+ grid-template-columns: 1fr 1px 1fr;
216
+ gap: var(--grid-gap);
217
+ }
218
+
219
+ .pairing-grid {
220
+ display: grid;
221
+ grid-template-columns: repeat(auto-fill, minmax(var(--item-min-width), 1fr));
222
+ gap: var(--grid-gap);
223
+ }
224
+
225
+ .pairing-cell {
226
+ aspect-ratio: 1 / 1;
227
+ position: relative;
228
+ border-radius: var(--sl-border-radius-medium);
229
+
230
+ /* Ring marking the cell a drop would land in. */
231
+ &::before {
232
+ content: "";
233
+ position: absolute;
234
+ inset: -4px;
235
+ border: 2px dashed var(--sl-color-primary-400);
236
+ border-radius: calc(var(--sl-border-radius-medium) + 3px);
237
+ pointer-events: none;
238
+ opacity: 0;
239
+ transition: var(--sl-transition-fast) opacity;
240
+ z-index: 2;
241
+ }
242
+
243
+ &.ww-drag-over::before {
244
+ opacity: 1;
245
+ }
246
+ }
247
+
248
+ .pairing-backdrop {
249
+ position: absolute;
250
+ inset: 0;
251
+ background-color: var(--sl-color-neutral-50);
252
+ border-radius: var(--sl-border-radius-medium);
253
+ transition: var(--sl-transition-fast) background-color;
254
+
255
+ .ww-drag-over > & {
256
+ background-color: var(--sl-color-primary-100);
257
+ }
258
+ }
259
+
260
+ .pairing-card {
261
+ /* All cards fill their cell exactly, so that dragging one never changes the layout. */
262
+ position: absolute;
263
+ inset: 0;
264
+ box-sizing: border-box;
265
+
266
+ border: 1px solid var(--sl-color-neutral-300);
267
+ border-radius: var(--sl-border-radius-medium);
268
+ background-color: var(--sl-color-neutral-0);
269
+ overflow: hidden;
270
+ user-select: none;
271
+
272
+ /* transform is left out: it is driven frame by frame while dragging. */
273
+ transition:
274
+ var(--sl-transition-fast) border-color,
275
+ var(--sl-transition-fast) background-color;
276
+
277
+ /* Overlay to prevent any pointer events on the card itself, so that all interactions stay inside this shadow DOM */
278
+ &::after {
279
+ content: "";
280
+ position: absolute;
281
+ inset: 0;
282
+ }
283
+
284
+ &[data-draggable] {
285
+ cursor: grab;
286
+ /* Required so that touch dragging does not scroll the page instead. */
287
+ touch-action: none;
288
+ }
289
+
290
+ &[data-draggable]:hover {
291
+ border-color: var(--sl-color-primary-300);
292
+ background-color: var(--sl-color-primary-50);
293
+ }
294
+
295
+ &.correct {
296
+ border-color: var(--sl-color-success-600);
297
+ background-color: var(--sl-color-success-50);
298
+ }
299
+
300
+ &.incorrect {
301
+ border-color: var(--sl-color-danger-600);
302
+ background-color: var(--sl-color-danger-50);
303
+ }
304
+
305
+ &.ww-dragging {
306
+ z-index: 1000;
307
+ pointer-events: none;
308
+ cursor: grabbing;
309
+ border-color: var(--sl-color-primary-400);
310
+ background-color: var(--sl-color-primary-50);
311
+ /* The dragged card scales via its transform, so that translating it stays a
312
+ 1:1 match for the pointer movement. */
313
+ scale: 1 !important;
314
+ }
315
+ }
316
+
317
+ .pairing-index {
318
+ position: absolute;
319
+ top: 4px;
320
+ right: 4px;
321
+ display: grid;
322
+ place-items: center;
323
+ box-sizing: border-box;
324
+ min-width: 1.6em;
325
+ height: 1.6em;
326
+ padding: 0 0.3em;
327
+ border-radius: var(--sl-border-radius-circle);
328
+ background-color: var(--sl-color-neutral-100);
329
+ line-height: 1;
330
+ color: var(--sl-color-neutral-700);
331
+ font-size: var(--sl-font-size-medium);
332
+ font-weight: var(--sl-font-weight-semibold);
333
+ }
334
+
335
+ /* A cell holding an assignment shows both cards side by side at a reduced size. */
336
+ .pairing-assigned {
337
+ .pairing-card-normal {
338
+ scale: 0.68;
339
+ transform-origin: bottom right;
340
+ }
341
+
342
+ .pairing-card-assigned {
343
+ scale: 0.68;
344
+ transform-origin: top left;
345
+ }
346
+
347
+ /* While its partner is dragged away, the remaining card takes the cell back. */
348
+ &.ww-drag-source .pairing-card-normal {
349
+ scale: 1;
350
+ }
351
+ }
352
+
353
+ .memory-grid {
354
+ display: grid;
355
+ grid-template-columns: repeat(auto-fill, minmax(var(--item-min-width), 1fr));
356
+ gap: var(--grid-gap);
357
+ }
358
+
359
+ .memory-card {
360
+ min-width: 0;
361
+ padding: 0;
362
+ border: 0;
363
+ aspect-ratio: 1 / 1;
364
+ background: transparent;
365
+ color: inherit;
366
+ font: inherit;
367
+ cursor: pointer;
368
+ perspective: 800px;
369
+ transition: opacity 160ms ease;
370
+
371
+ &:focus-visible {
372
+ outline: var(--sl-focus-ring-width) solid var(--sl-focus-ring-color);
373
+ outline-offset: var(--sl-focus-ring-offset);
374
+ border-radius: var(--sl-border-radius-medium);
375
+ }
376
+
377
+ &.matched {
378
+ opacity: 0.55;
379
+ }
380
+
381
+ &:disabled {
382
+ cursor: default;
383
+ }
384
+
385
+ &.flipped .memory-card-inner {
386
+ transform: rotateY(180deg);
387
+ }
388
+ }
389
+
390
+ .memory-card-inner {
391
+ position: relative;
392
+ display: block;
393
+ width: 100%;
394
+ height: 100%;
395
+ transition: transform 300ms ease;
396
+ transform-style: preserve-3d;
397
+ }
398
+
399
+ .memory-card-face {
400
+ position: absolute;
401
+ inset: 0;
402
+ display: grid;
403
+ place-items: center;
404
+ box-sizing: border-box;
405
+ border: 1px solid var(--sl-color-neutral-300);
406
+ border-radius: var(--sl-border-radius-medium);
407
+ overflow: hidden;
408
+ background: var(--sl-color-neutral-0);
409
+ backface-visibility: hidden;
410
+ }
411
+
412
+ .memory-card-back {
413
+ border: 0;
414
+ background: var(--sl-color-primary-600);
415
+ color: var(--sl-color-neutral-0);
416
+ font-size: calc(var(--item-min-width) / 3);
417
+ font-weight: var(--sl-font-weight-bold);
418
+ }
419
+
420
+ .memory-card-front {
421
+ transform: rotateY(180deg);
422
+
423
+ slot {
424
+ width: 100%;
425
+ height: 100%;
426
+ }
427
+ }
428
+
429
+ @media (prefers-reduced-motion: reduce) {
430
+ .memory-card-inner {
431
+ transition-duration: 0ms;
432
+ }
433
+ }
434
+ `;
435
+
436
+ /**
437
+ * How learners match the items.
438
+ *
439
+ * - `pairing`: All items are visible and learners drag them together.
440
+ * - `memory`: The items are face down and learners uncover two at a time.
441
+ */
442
+ @property({ type: String, attribute: true, reflect: true })
443
+ accessor mode: PairingMode = "pairing";
444
+
445
+ /**
446
+ * The pairs counting as correct, as tuples of the two item IDs. It is obfuscated in the markup so that learners cannot read it directly.
447
+ */
448
+ @property({ type: Array, attribute: true, reflect: true, converter: encryptedProperty })
449
+ accessor solution: [string, string][] = [];
450
+
451
+ /** @internal */
452
+ @queryAll("slot")
453
+ accessor slots!: NodeListOf<HTMLSlotElement>;
454
+
455
+ @state()
456
+ private accessor graded = false;
457
+
458
+ @state()
459
+ private accessor detailedFeedback = false;
460
+
461
+ private get showFeedback() {
462
+ return this.graded && this.detailedFeedback;
463
+ }
464
+
465
+ /** Index of the pair an item belongs to, or -1 if it belongs to none. */
466
+ private pairIndex(itemId: string) {
467
+ return this.solution.findIndex(pair => pair.includes(itemId));
468
+ }
469
+
470
+ private addPair() {
471
+ const insertItem = () => {
472
+ // Insert new items at a random position to not make the pairing obvious from the order of the items in the DOM.
473
+ const randomIndex = Math.floor(Math.random() * (this.children.length + 1));
474
+ const newItem = document.createElement("webwriter-pairing-item");
475
+ if (randomIndex === this.children.length) this.appendChild(newItem);
476
+ else this.insertBefore(newItem, this.children[randomIndex]);
477
+ return newItem.id;
478
+ };
479
+
480
+ this.solution = [...this.solution, [insertItem(), insertItem()]];
481
+ }
482
+
483
+ private removePair(a: string, b: string) {
484
+ this.querySelector(`webwriter-pairing-item#${a}`)?.remove();
485
+ this.querySelector(`webwriter-pairing-item#${b}`)?.remove();
486
+ this.solution = this.solution.filter(([idA, idB]) => idA !== a && idB !== b);
487
+ }
488
+
489
+ private renderAuthoringView() {
490
+ return html`
491
+ <draggable-grid
492
+ class="pairs"
493
+ @reorder=${({ detail }: DraggableGridReorderEvent) => {
494
+ const pairs = this.solution;
495
+ this.solution = detail.order.map(id => pairs[Number(id.substring("pair-".length))]);
496
+ }}
497
+ >
498
+ ${this.solution.map(
499
+ ([a, b], index) =>
500
+ html`<div class="pair" id=${`pair-${index}`}>
501
+ <div class="actions">
502
+ <sl-icon-button class="drag-handle" data-drag-handle src=${GripHorizontalIcon}></sl-icon-button>
503
+ <sl-icon-button src=${TrashIcon} @click=${() => this.removePair(a, b)}></sl-icon-button>
504
+ </div>
505
+ <div class="pair-content">
506
+ <slot data-child-id=${a}></slot>
507
+ <div class="divider"></div>
508
+ <slot data-child-id=${b}></slot>
509
+ </div>
510
+ </div>`,
511
+ )}
512
+ <div @click=${this.addPair} id="add-pair" data-not-draggable>
513
+ <div class="label"><sl-icon src=${PlusIcon}></sl-icon>${msg("Add Pair")}</div>
514
+ <!-- Required so that the height of add-pair always matches the height of the pairings -->
515
+ <div class="size-placeholder"></div>
516
+ </div>
517
+ </draggable-grid>
518
+ `;
519
+ }
520
+
521
+ @state()
522
+ private accessor pairingQuiz: PairingQuizState | null = null;
523
+ private pairingDragState: PairingDragState | null = null;
524
+ private pairingSettleTimeout?: ReturnType<typeof setTimeout>;
525
+
526
+ private resetPairingQuiz() {
527
+ this.pairingQuiz = {
528
+ leftItemIds: randomizeArray(this.solution.map(([a]) => a)),
529
+ rightItemIds: randomizeArray(this.solution.map(([, b]) => b)),
530
+ assignments: new BiMap(),
531
+ };
532
+ }
533
+
534
+ private renderPairingView() {
535
+ if (!this.pairingQuiz) return nothing;
536
+ const { leftItemIds, rightItemIds, assignments } = this.pairingQuiz;
537
+ return html`
538
+ <div
539
+ class="pairing-container"
540
+ @pointerdown=${this.onPairingPointerDown}
541
+ @pointermove=${this.onPairingPointerMove}
542
+ @pointerup=${this.onPairingPointerEnd}
543
+ @pointercancel=${this.onPairingPointerEnd}
544
+ @lostpointercapture=${this.onPairingPointerEnd}
545
+ >
546
+ <div class="pairing-grid pairing-left" data-drop-target>
547
+ ${leftItemIds.map(
548
+ id =>
549
+ html`<div class="pairing-cell" data-cell-id=${id}>
550
+ <div class="pairing-backdrop"></div>
551
+ ${assignments.hasKey(id) ? nothing : this.renderPairingCard(id)}
552
+ </div>`,
553
+ )}
554
+ </div>
555
+ <div class="divider"></div>
556
+ <div class="pairing-grid pairing-right">
557
+ ${rightItemIds.map(id => {
558
+ const assignedId = assignments.getKey(id);
559
+ const correct = assignedId !== undefined && this.pairIndex(assignedId) === this.pairIndex(id);
560
+ const feedback = this.showFeedback ? (correct ? " correct" : " incorrect") : "";
561
+ return html`<div
562
+ class=${classMap({ "pairing-cell": true, "pairing-assigned": !!assignedId })}
563
+ data-drop-target
564
+ data-drop-target-id=${id}
565
+ >
566
+ ${this.renderPairingCard(id, "pairing-card-normal", false)}
567
+ ${assignedId ? this.renderPairingCard(assignedId, `pairing-card-assigned${feedback}`) : nothing}
568
+ </div>`;
569
+ })}
570
+ </div>
571
+ </div>
572
+ `;
573
+ }
574
+
575
+ private renderPairingCard(id: string, variant = "", draggable = true) {
576
+ const index = this.showFeedback ? this.pairIndex(id) : -1;
577
+ return html`<div class="pairing-card ${variant}" data-card-id=${id} ?data-draggable=${draggable && !this.graded}>
578
+ <slot data-child-id=${id}></slot>
579
+ ${index < 0 ? nothing : html`<span class="pairing-index">${index + 1}</span>`}
580
+ </div>`;
581
+ }
582
+
583
+ private get pairingCards() {
584
+ return Array.from(this.renderRoot.querySelectorAll<HTMLElement>(".pairing-card[data-card-id]"));
585
+ }
586
+
587
+ private animatePairingCard(card: HTMLElement, from: DOMRect, duration = PAIRING_SETTLE_MS) {
588
+ const invert = prefersReducedMotion() ? null : transformTo(card, from);
589
+ if (!invert || (Math.abs(invert.x) < 0.5 && Math.abs(invert.y) < 0.5 && Math.abs(invert.scale - 1) < 0.005))
590
+ return false;
591
+
592
+ card.style.transform = `translate(${invert.x}px, ${invert.y}px) scale(${invert.scale})`;
593
+ // Flush the inverted position so that the transition starts from it.
594
+ void card.offsetHeight;
595
+ card.style.transition = `transform ${duration}ms ${PAIRING_SETTLE_EASING}`;
596
+ card.style.transform = "";
597
+ return true;
598
+ }
599
+
600
+ private flipPairingCards(from: Map<string, DOMRect>, droppedCardId: string) {
601
+ this.finishPairingSettle();
602
+
603
+ let settling = false;
604
+ for (const card of this.pairingCards) {
605
+ const previous = from.get(card.dataset.cardId!);
606
+ if (!previous || !this.animatePairingCard(card, previous)) continue;
607
+ // Cards paint in DOM order, so one flying back to the left grid would pass behind the
608
+ // cards it travels over, and the dropped one behind the card it swapped with.
609
+ card.style.zIndex = card.dataset.cardId === droppedCardId ? "1001" : "1000";
610
+ settling = true;
611
+ }
612
+ if (settling) this.pairingSettleTimeout = setTimeout(() => this.finishPairingSettle(), PAIRING_SETTLE_MS);
613
+ }
614
+
615
+ private finishPairingSettle() {
616
+ clearTimeout(this.pairingSettleTimeout);
617
+ this.pairingSettleTimeout = undefined;
618
+ for (const card of this.pairingCards) {
619
+ card.style.transition = "";
620
+ card.style.transform = "";
621
+ card.style.transformOrigin = "";
622
+ card.style.zIndex = "";
623
+ }
624
+ }
625
+
626
+ private pairingDropCell(state: PairingDragState, clientX: number, clientY: number) {
627
+ const target = this.shadowRoot!.elementFromPoint(clientX, clientY)?.closest<HTMLElement>("[data-drop-target]");
628
+ if (!target || !this.pairingQuiz) return null;
629
+
630
+ // Only the right cells carry an ID; the left grid takes a card back into its own cell.
631
+ const { assignments } = this.pairingQuiz;
632
+ const rightId = target.dataset.dropTargetId;
633
+ if (rightId === undefined)
634
+ return assignments.hasKey(state.cardId)
635
+ ? this.renderRoot.querySelector<HTMLElement>(`.pairing-left [data-cell-id="${state.cardId}"]`)
636
+ : null;
637
+ return assignments.getValue(state.cardId) === rightId ? null : target;
638
+ }
639
+
640
+ private applyPairingDrop(state: PairingDragState, cell: HTMLElement) {
641
+ const { assignments } = this.pairingQuiz!;
642
+ const rightId = cell.dataset.dropTargetId;
643
+ if (rightId === undefined) {
644
+ assignments.deleteKey(state.cardId);
645
+ return;
646
+ }
647
+
648
+ // If both cells are taken, the card that has to make room takes the freed one.
649
+ const displacedId = assignments.getKey(rightId);
650
+ const originId = assignments.getValue(state.cardId);
651
+ if (displacedId !== undefined && originId !== undefined) assignments.set(displacedId, originId);
652
+ assignments.set(state.cardId, rightId);
653
+ }
654
+
655
+ private requestPairingAnimationFrame() {
656
+ if (this.pairingDragState?.frame === null)
657
+ this.pairingDragState.frame = requestAnimationFrame(time => this.onPairingAnimationFrame(time));
658
+ }
659
+
660
+ private autoScrollPairing(state: PairingDragState, deltaTime: number) {
661
+ const edgeSize = Math.min(AUTO_SCROLL_EDGE_SIZE, window.innerHeight / 4);
662
+ const direction = state.clientY < edgeSize ? -1 : state.clientY > window.innerHeight - edgeSize ? 1 : 0;
663
+ const scrollingElement = document.scrollingElement ?? document.documentElement;
664
+ const scrollY = scrollingElement.scrollTop;
665
+
666
+ const distance = direction * AUTO_SCROLL_SPEED * (deltaTime / 1000);
667
+ scrollingElement.scrollTop = Math.min(state.maxPageScrollY, Math.max(0, scrollY + distance));
668
+ return scrollingElement.scrollTop !== scrollY;
669
+ }
670
+
671
+ private onPairingAnimationFrame(time: number) {
672
+ const state = this.pairingDragState;
673
+ if (!state) return;
674
+ state.frame = null;
675
+ // Clamped so that a dropped frame cannot scroll the page in one jump.
676
+ const scrolled = this.autoScrollPairing(state, Math.min(64, time - state.lastFrameTime));
677
+ state.lastFrameTime = time;
678
+
679
+ const lift = Math.min(1, (time - state.startTime) / PAIRING_LIFT_MS);
680
+ const scale = state.base.scale + (1 - state.base.scale) * easeOut(lift);
681
+ const x = state.base.x + state.clientX + window.scrollX - state.startPageX;
682
+ const y = state.base.y + state.clientY + window.scrollY - state.startPageY;
683
+ state.element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
684
+
685
+ const highlight = this.pairingDropCell(state, state.clientX, state.clientY);
686
+ if (highlight !== state.highlight) {
687
+ state.highlight?.classList.remove("ww-drag-over");
688
+ highlight?.classList.add("ww-drag-over");
689
+ state.highlight = highlight;
690
+ }
691
+
692
+ if (scrolled || lift < 1) this.requestPairingAnimationFrame();
693
+ }
694
+
695
+ private onPairingPointerDown(event: PointerEvent) {
696
+ if (this.pairingDragState || !this.pairingQuiz || !event.isPrimary || event.button !== 0) return;
697
+
698
+ const card = event
699
+ .composedPath()
700
+ .find(target => target instanceof HTMLElement && target.matches("[data-draggable]")) as HTMLElement | undefined;
701
+ if (!card?.dataset.cardId) return;
702
+
703
+ event.preventDefault();
704
+ card.setPointerCapture(event.pointerId);
705
+
706
+ // Measure before any styles change, so that a card picked up mid-settle stays put.
707
+ const from = card.getBoundingClientRect();
708
+ this.finishPairingSettle();
709
+
710
+ // The card grows around the pointer, which keeps the grabbed spot under the cursor.
711
+ const originX = ((event.clientX - from.x) / from.width) * 100;
712
+ const originY = ((event.clientY - from.y) / from.height) * 100;
713
+ card.style.transformOrigin = `${originX}% ${originY}%`;
714
+ card.classList.add("ww-dragging");
715
+ const base = transformTo(card, from) ?? { x: 0, y: 0, scale: 1 };
716
+ card.style.transform = `translate(${base.x}px, ${base.y}px) scale(${base.scale})`;
717
+
718
+ const sourceCell = card.closest<HTMLElement>(".pairing-assigned");
719
+ const partner = sourceCell?.querySelector<HTMLElement>(".pairing-card-normal");
720
+ if (sourceCell && partner) {
721
+ const partnerFrom = partner.getBoundingClientRect();
722
+ sourceCell.classList.add("ww-drag-source");
723
+ this.animatePairingCard(partner, partnerFrom, PAIRING_LIFT_MS);
724
+ }
725
+
726
+ const scrollingElement = document.scrollingElement ?? document.documentElement;
727
+ const now = performance.now();
728
+ this.pairingDragState = {
729
+ element: card,
730
+ cardId: card.dataset.cardId,
731
+ pointerId: event.pointerId,
732
+ base,
733
+ sourceCell: sourceCell ?? null,
734
+ startPageX: event.pageX,
735
+ startPageY: event.pageY,
736
+ clientX: event.clientX,
737
+ clientY: event.clientY,
738
+ startTime: now,
739
+ lastFrameTime: now,
740
+ frame: null,
741
+ highlight: null,
742
+ maxPageScrollY: Math.max(0, scrollingElement.scrollHeight - scrollingElement.clientHeight),
743
+ };
744
+ this.requestPairingAnimationFrame();
745
+ }
746
+
747
+ private onPairingPointerMove(event: PointerEvent) {
748
+ if (event.pointerId !== this.pairingDragState?.pointerId) return;
749
+ event.preventDefault();
750
+
751
+ this.pairingDragState.clientX = event.clientX;
752
+ this.pairingDragState.clientY = event.clientY;
753
+ this.requestPairingAnimationFrame();
754
+ }
755
+
756
+ private onPairingPointerEnd(event: PointerEvent) {
757
+ const state = this.pairingDragState;
758
+ if (!state || event.pointerId !== state.pointerId) return;
759
+ this.pairingDragState = null;
760
+
761
+ if (state.frame !== null) cancelAnimationFrame(state.frame);
762
+ state.highlight?.classList.remove("ww-drag-over");
763
+ if (state.element.hasPointerCapture(event.pointerId)) state.element.releasePointerCapture(event.pointerId);
764
+
765
+ const cell = event.type === "pointerup" ? this.pairingDropCell(state, state.clientX, state.clientY) : null;
766
+
767
+ // Capture the card where the pointer left it, then let it fly into its cell from there.
768
+ const from = new Map(this.pairingCards.map(card => [card.dataset.cardId!, card.getBoundingClientRect()]));
769
+ state.element.classList.remove("ww-dragging");
770
+ state.sourceCell?.classList.remove("ww-drag-source");
771
+ if (cell) {
772
+ this.applyPairingDrop(state, cell);
773
+ this.requestUpdate("pairingQuiz");
774
+ }
775
+ this.updateComplete.then(() => this.flipPairingCards(from, state.cardId));
776
+ }
777
+
778
+ @state()
779
+ private accessor memoryGame: MemoryGameState | null = null;
780
+ private flipBackTimeout?: ReturnType<typeof setTimeout>;
781
+
782
+ private resetMemoryGame() {
783
+ if (this.flipBackTimeout !== undefined) clearTimeout(this.flipBackTimeout);
784
+ this.flipBackTimeout = undefined;
785
+ this.memoryGame = {
786
+ itemIds: randomizeArray(this.solution.flat()),
787
+ flippedItemIds: [],
788
+ matchedItemIds: [],
789
+ };
790
+ }
791
+
792
+ private flipMemoryItem(id: string) {
793
+ if (this.graded || !this.memoryGame) return;
794
+ const { flippedItemIds, matchedItemIds } = this.memoryGame;
795
+ if (this.flipBackTimeout !== undefined || flippedItemIds.includes(id) || matchedItemIds.includes(id)) return;
796
+
797
+ if (flippedItemIds.length === 0) {
798
+ this.memoryGame = { ...this.memoryGame, flippedItemIds: [id] };
799
+ return;
800
+ }
801
+
802
+ const firstId = flippedItemIds[0];
803
+ this.memoryGame = { ...this.memoryGame, flippedItemIds: [firstId, id] };
804
+ const isPair = this.solution.some(([a, b]) => (a === firstId && b === id) || (a === id && b === firstId));
805
+
806
+ if (isPair) {
807
+ this.memoryGame = {
808
+ ...this.memoryGame,
809
+ flippedItemIds: [],
810
+ matchedItemIds: [...matchedItemIds, firstId, id],
811
+ };
812
+ return;
813
+ }
814
+
815
+ this.flipBackTimeout = setTimeout(() => {
816
+ this.flipBackTimeout = undefined;
817
+ this.memoryGame = { ...this.memoryGame!, flippedItemIds: [] };
818
+ }, 800);
819
+ }
820
+
821
+ private renderMemoryGame() {
822
+ if (!this.memoryGame) return nothing;
823
+ return html`<div class="memory-grid">
824
+ ${this.memoryGame.itemIds.map(id => {
825
+ const matched = this.memoryGame!.matchedItemIds.includes(id);
826
+ const flipped = matched || this.memoryGame!.flippedItemIds.includes(id);
827
+
828
+ return html`<button
829
+ type="button"
830
+ class=${classMap({ "memory-card": true, flipped, matched })}
831
+ ?disabled=${this.graded || matched}
832
+ @click=${() => this.flipMemoryItem(id)}
833
+ >
834
+ <span class="memory-card-inner">
835
+ <span class="memory-card-face memory-card-back">?</span>
836
+ <span class="memory-card-face memory-card-front">
837
+ <slot data-child-id=${id}></slot>
838
+ </span>
839
+ </span>
840
+ </button>`;
841
+ })}
842
+ </div>`;
843
+ }
844
+
845
+ /** @internal */
846
+ get dynamicActions() {
847
+ return {
848
+ addPair: { label: { _: msg("Add Pair") } },
849
+ } as Record<string, ActionDeclaration>;
850
+ }
851
+
852
+ /** @internal */
853
+ get dynamicOptions() {
854
+ const options: Record<string, OptionDeclaration> = {
855
+ mode: {
856
+ type: "select",
857
+ label: { _: msg("Mode") },
858
+ options: [
859
+ { value: "pairing", label: { _: msg("Pairing") } },
860
+ { value: "memory", label: { _: msg("Memory") } },
861
+ ],
862
+ },
863
+ };
864
+
865
+ return options;
866
+ }
867
+
868
+ checkValidity(): boolean {
869
+ return true;
870
+ }
871
+
872
+ checkAnswer(detailedFeedback: boolean): number {
873
+ this.graded = true;
874
+ this.detailedFeedback = detailedFeedback;
875
+ if (this.solution.length === 0) return 0;
876
+
877
+ if (this.mode === "memory") return (this.memoryGame?.matchedItemIds.length ?? 0) / 2 / this.solution.length;
878
+
879
+ const assignments = this.pairingQuiz?.assignments;
880
+ const correct = this.solution.filter(([a, b]) => assignments?.getValue(a) === b);
881
+ return correct.length / this.solution.length;
882
+ }
883
+
884
+ reset(): void {
885
+ this.graded = false;
886
+ this.detailedFeedback = false;
887
+ if (this.mode === "memory") this.resetMemoryGame();
888
+ else this.resetPairingQuiz();
889
+ }
890
+
891
+ protected firstUpdated(changed: PropertyValues): void {
892
+ super.firstUpdated(changed);
893
+ encryptPlaintextAttributes(this);
894
+ }
895
+
896
+ protected updated(changed: PropertyValues): void {
897
+ if (changed.has("solution") && this.solution.length === 0) {
898
+ // Ensure that there is always at least one pair
899
+ this.updateComplete.then(() => this.addPair());
900
+ }
901
+
902
+ const children = Array.from(this.children);
903
+ this.slots.forEach(slot => {
904
+ const refId = slot.dataset.childId;
905
+ const item = children.find(child => child.id === refId);
906
+ if (item) slot.assign(item);
907
+ });
908
+ }
909
+
910
+ protected willUpdate(changed: PropertyValues<this>): void {
911
+ if (changed.has("solution")) {
912
+ if (this.mode === "pairing") this.resetPairingQuiz();
913
+ else if (this.mode === "memory") this.resetMemoryGame();
914
+ }
915
+ }
916
+
917
+ disconnectedCallback(): void {
918
+ super.disconnectedCallback();
919
+ if (this.flipBackTimeout !== undefined) clearTimeout(this.flipBackTimeout);
920
+ this.flipBackTimeout = undefined;
921
+ if (this.pairingSettleTimeout !== undefined) clearTimeout(this.pairingSettleTimeout);
922
+ this.pairingSettleTimeout = undefined;
923
+ if (this.pairingDragState?.frame != null) cancelAnimationFrame(this.pairingDragState.frame);
924
+ this.pairingDragState = null;
925
+ }
926
+
927
+ render() {
928
+ if (this.isContentEditable) return this.renderAuthoringView();
929
+ if (this.mode === "pairing") return this.renderPairingView();
930
+ if (this.mode === "memory") return this.renderMemoryGame();
931
+ console.warn("[webwriter-pairing] Invalid mode:", this.mode);
932
+ return nothing;
933
+ }
934
+ }