@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,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
+ }