@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.
- package/.github/workflows/publish.yml +8 -0
- package/LICENSE +6 -6
- package/README.md +778 -0
- package/assets/icons/grip-horizontal.svg +8 -0
- package/assets/icons/grip-vertical.svg +8 -0
- package/assets/icons/truefalse.svg +6 -0
- package/custom-elements.json +2779 -0
- package/custom.d.ts +180 -176
- package/dist/api/index.d.ts +27 -0
- package/dist/api/index.js +40 -0
- package/dist/migrate.js +1434 -0
- package/dist/widgets/webwriter-choice-item.js +4036 -3895
- package/dist/widgets/webwriter-choice.js +2269 -5821
- package/dist/widgets/webwriter-gap-item.js +3987 -0
- package/dist/widgets/webwriter-gap.js +2908 -0
- package/dist/widgets/webwriter-mark.js +4335 -3489
- package/dist/widgets/webwriter-order-item.js +4014 -3306
- package/dist/widgets/webwriter-order.js +2944 -4386
- package/dist/widgets/webwriter-pairing-item.js +1058 -2421
- package/dist/widgets/webwriter-pairing.js +2993 -4383
- package/dist/widgets/webwriter-quiz-hint.js +2469 -0
- package/dist/widgets/webwriter-quiz.js +4933 -6099
- package/dist/widgets/webwriter-speech.js +6077 -5230
- package/dist/widgets/webwriter-task-prompt.js +4775 -2165
- package/dist/widgets/webwriter-task.js +2364 -8382
- package/dist/widgets/webwriter-text.js +4169 -4337
- package/dist/widgets/webwriter-true-false.js +4674 -0
- package/editing-config.json +106 -0
- package/lit-localize.json +21 -15
- package/localization/de.xlf +407 -74
- package/localization/es.xlf +407 -69
- package/localization/fr.xlf +407 -69
- package/localization/generated/de.ts +112 -29
- package/localization/generated/es.ts +112 -29
- package/localization/generated/fr.ts +112 -29
- package/localization/generated/index.d.ts +5 -0
- package/localization/generated/index.js +2 -2
- package/localization/generated/it.ts +112 -29
- package/localization/generated/locale-codes.js +31 -79
- package/localization/generated/nl.ts +112 -29
- package/localization/it.xlf +407 -69
- package/localization/nl.xlf +407 -69
- package/package.json +119 -252
- package/scripts/apply-translations.mjs +97 -0
- package/src/api/index.ts +72 -0
- package/src/lib/artboard.ts +686 -0
- package/src/lib/draggable-grid.ts +432 -0
- package/src/lib/encrypted-property.ts +202 -0
- package/src/lib/hint-popup.ts +152 -0
- package/src/lib/utils.ts +97 -0
- package/src/lib/webwriter-quirks.ts +121 -0
- package/src/migrate/index.ts +622 -0
- package/src/migrate/legacy-solution.ts +362 -0
- package/src/snippets/order.html +9 -8
- package/src/widgets/choice/webwriter-choice-item.ts +316 -0
- package/src/widgets/choice/webwriter-choice.ts +328 -0
- package/src/widgets/choice/webwriter-true-false.ts +113 -0
- package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
- package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
- package/src/widgets/dnd/webwriter-dnd.ts +36 -0
- package/src/widgets/gap/webwriter-gap-item.ts +250 -0
- package/src/widgets/gap/webwriter-gap.ts +447 -0
- package/src/widgets/order/webwriter-order-item.ts +243 -0
- package/src/widgets/order/webwriter-order.ts +282 -0
- package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
- package/src/widgets/pairing/webwriter-pairing.ts +934 -0
- package/src/widgets/speech/audio-player.ts +325 -0
- package/src/widgets/speech/audio-recorder.ts +285 -0
- package/src/widgets/speech/waveform.ts +113 -0
- package/src/widgets/speech/webwriter-speech.ts +87 -0
- package/src/widgets/webwriter-mark.ts +374 -432
- package/src/widgets/webwriter-quiz-hint.ts +36 -0
- package/src/widgets/webwriter-quiz.ts +320 -282
- package/src/widgets/webwriter-task-prompt.ts +49 -24
- package/src/widgets/webwriter-task.ts +216 -547
- package/src/widgets/webwriter-text.ts +291 -156
- package/tsconfig.api.json +12 -0
- package/tsconfig.json +8 -7
- package/dist/widgets/webwriter-choice.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.js +0 -5866
- package/dist/widgets/webwriter-cloze.css +0 -444
- package/dist/widgets/webwriter-cloze.js +0 -3786
- package/dist/widgets/webwriter-mark.css +0 -444
- package/dist/widgets/webwriter-order.css +0 -444
- package/dist/widgets/webwriter-pairing-item.css +0 -444
- package/dist/widgets/webwriter-pairing.css +0 -444
- package/dist/widgets/webwriter-quiz.css +0 -444
- package/dist/widgets/webwriter-task-explainer.css +0 -444
- package/dist/widgets/webwriter-task-explainer.js +0 -2872
- package/dist/widgets/webwriter-task-hint.css +0 -444
- package/dist/widgets/webwriter-task-hint.js +0 -2811
- package/dist/widgets/webwriter-task.css +0 -444
- package/dist/widgets/webwriter-text.css +0 -444
- package/localization/bg.xlf +0 -69
- package/localization/cs.xlf +0 -69
- package/localization/da.xlf +0 -69
- package/localization/el.xlf +0 -69
- package/localization/et.xlf +0 -69
- package/localization/fi.xlf +0 -69
- package/localization/generated/bg.ts +0 -30
- package/localization/generated/cs.ts +0 -30
- package/localization/generated/da.ts +0 -30
- package/localization/generated/el.ts +0 -30
- package/localization/generated/et.ts +0 -30
- package/localization/generated/fi.ts +0 -30
- package/localization/generated/hu.ts +0 -30
- package/localization/generated/id.ts +0 -30
- package/localization/generated/ja.ts +0 -30
- package/localization/generated/ko.ts +0 -30
- package/localization/generated/lt.ts +0 -30
- package/localization/generated/lv.ts +0 -30
- package/localization/generated/nb.ts +0 -30
- package/localization/generated/pl.ts +0 -30
- package/localization/generated/pt-BR.ts +0 -30
- package/localization/generated/pt-PT.ts +0 -30
- package/localization/generated/ro.ts +0 -30
- package/localization/generated/ru.ts +0 -30
- package/localization/generated/sk.ts +0 -30
- package/localization/generated/sl.ts +0 -30
- package/localization/generated/sv.ts +0 -30
- package/localization/generated/tr.ts +0 -30
- package/localization/generated/uk.ts +0 -30
- package/localization/generated/zh-hans.ts +0 -30
- package/localization/hu.xlf +0 -69
- package/localization/id.xlf +0 -69
- package/localization/ja.xlf +0 -69
- package/localization/ko.xlf +0 -69
- package/localization/lt.xlf +0 -69
- package/localization/lv.xlf +0 -69
- package/localization/nb.xlf +0 -69
- package/localization/pl.xlf +0 -69
- package/localization/pt-BR.xlf +0 -69
- package/localization/pt-PT.xlf +0 -69
- package/localization/ro.xlf +0 -69
- package/localization/ru.xlf +0 -69
- package/localization/sk.xlf +0 -69
- package/localization/sl.xlf +0 -69
- package/localization/sv.xlf +0 -69
- package/localization/tr.xlf +0 -69
- package/localization/uk.xlf +0 -69
- package/localization/zh-hans.xlf +0 -69
- package/src/lib/combobox.ts +0 -456
- package/src/lib/highlighter-fill.svg +0 -6
- package/src/snippets/Beispiel-Choice.html +0 -57
- package/src/snippets/Beispiel-Mark.html +0 -14
- package/src/snippets/Beispiel-Order.html +0 -52
- package/src/snippets/Beispiel-Text.html +0 -20
- package/src/snippets/blank.html +0 -1
- package/src/snippets/choice.html +0 -8
- package/src/snippets/cloze.html +0 -4
- package/src/snippets/mark.html +0 -4
- package/src/snippets/pairing.html +0 -10
- package/src/snippets/speech.html +0 -4
- package/src/snippets/text.html +0 -4
- package/src/snippets/wordsearch.html +0 -4
- package/src/widgets/webwriter-choice-item.ts +0 -254
- package/src/widgets/webwriter-choice.ts +0 -353
- package/src/widgets/webwriter-cloze-gap.ts +0 -216
- package/src/widgets/webwriter-cloze.ts +0 -136
- package/src/widgets/webwriter-order-item.ts +0 -441
- package/src/widgets/webwriter-order.ts +0 -327
- package/src/widgets/webwriter-pairing-item.ts +0 -161
- package/src/widgets/webwriter-pairing.ts +0 -193
- package/src/widgets/webwriter-speech.ts +0 -273
- package/src/widgets/webwriter-task-explainer.ts +0 -43
- 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
|
+
}
|