@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
|
@@ -1,432 +1,374 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
disconnectedCallback(): void {
|
|
378
|
-
super.disconnectedCallback()
|
|
379
|
-
this.observer?.disconnect()
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
@eventOptions({passive: true})
|
|
383
|
-
@action({label: {_: "Toggle Highlight"}})
|
|
384
|
-
handleHighlight(e?: PointerEvent) {
|
|
385
|
-
if(e && this.isContentEditable && e.type === "click") {
|
|
386
|
-
return
|
|
387
|
-
}
|
|
388
|
-
else if(e && !this.isContentEditable && e.type === "contextmenu") {
|
|
389
|
-
return
|
|
390
|
-
}
|
|
391
|
-
// convert click to caret position
|
|
392
|
-
|
|
393
|
-
const {textNode, offset} = e
|
|
394
|
-
? getCaretPositionFromPoint(e)
|
|
395
|
-
: {textNode: document.getSelection().anchorNode, offset: document.getSelection().anchorOffset}
|
|
396
|
-
// convert caret position to segment range
|
|
397
|
-
const segment = this.segments.filter(seg => seg.isWordLike).find(({index, segment}) => index <= offset && offset <= index + segment.length)
|
|
398
|
-
// add or remove segment range from highlights
|
|
399
|
-
if(segment) {
|
|
400
|
-
const range = new SerializableRange()
|
|
401
|
-
const start = segment.index
|
|
402
|
-
const end = segment.index + segment.segment.length
|
|
403
|
-
range.setStart(textNode, start)
|
|
404
|
-
range.setEnd(textNode, end)
|
|
405
|
-
const key = this.isContentEditable? "solution": "value"
|
|
406
|
-
const sameRange = this[key].find(r => r.startContainer === textNode && r.endContainer === textNode && r.startOffset === start && r.endOffset == end)
|
|
407
|
-
if(sameRange) {
|
|
408
|
-
this[key] = this[key].filter(r => r !== sameRange)
|
|
409
|
-
}
|
|
410
|
-
else {
|
|
411
|
-
this[key] = [...this[key], range]
|
|
412
|
-
}
|
|
413
|
-
this.dispatchEvent(new CustomEvent("ww-answer-change", {
|
|
414
|
-
bubbles: true,
|
|
415
|
-
composed: true
|
|
416
|
-
}))
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
reset() {
|
|
421
|
-
this.value = this.solution = []
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
reportSolution() {}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
render() {
|
|
428
|
-
return html`
|
|
429
|
-
<slot style=${styleMap({"--ww-placeholder": `"${msg("Text to Highlight")}"`})} ?data-empty=${!this.textContent} @click=${this.handleHighlight} @contextmenu=${this.handleHighlight}></slot>
|
|
430
|
-
`
|
|
431
|
-
}
|
|
432
|
-
}
|
|
1
|
+
import { msg } from "@lit/localize";
|
|
2
|
+
import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js";
|
|
3
|
+
import { action, LitElementWw, type OptionDeclaration } from "@webwriter/lit";
|
|
4
|
+
import HighlighterIcon from "bootstrap-icons/icons/highlighter.svg";
|
|
5
|
+
import { css, html, type PropertyValues } from "lit";
|
|
6
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
7
|
+
import { createRef, ref } from "lit/directives/ref.js";
|
|
8
|
+
import { styleMap } from "lit/directives/style-map.js";
|
|
9
|
+
import { name as packageId } from "../../package.json";
|
|
10
|
+
import { IWebWriterQuizType, registerQuizType } from "../api";
|
|
11
|
+
import { encryptedProperty, encryptPlaintextAttributes } from "../lib/encrypted-property";
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
interface HTMLElementTagNameMap {
|
|
15
|
+
"webwriter-mark": WebWriterMark;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type MarkGradingMethod = "pass-fail" | "partial";
|
|
20
|
+
|
|
21
|
+
registerQuizType({
|
|
22
|
+
packageId,
|
|
23
|
+
widgetPosition: 20,
|
|
24
|
+
id: "webwriter-mark",
|
|
25
|
+
getName: () => msg("Mark"),
|
|
26
|
+
icon: HighlighterIcon,
|
|
27
|
+
createInstance: () => document.createElement("webwriter-mark"),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
type WordSegment = { node: Text; start: number; end: number };
|
|
31
|
+
type HighlightGroup = "marked" | "correct" | "missed" | "wrong";
|
|
32
|
+
|
|
33
|
+
function rangesOverlap(a: Range, b: Range): boolean {
|
|
34
|
+
return a.compareBoundaryPoints(Range.END_TO_START, b) < 0 && a.compareBoundaryPoints(Range.START_TO_END, b) > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* An answer where learners mark the words of a text that fit the question.
|
|
39
|
+
*
|
|
40
|
+
* The text must be assigned to the default slot; the element itself must be
|
|
41
|
+
* assigned to the default slot of a `<webwriter-task>`.
|
|
42
|
+
*/
|
|
43
|
+
@customElement("webwriter-mark")
|
|
44
|
+
export class WebWriterMark extends LitElementWw implements IWebWriterQuizType {
|
|
45
|
+
/** @internal */
|
|
46
|
+
get dynamicOptions(): Record<string, OptionDeclaration> {
|
|
47
|
+
return {
|
|
48
|
+
"grading-method": {
|
|
49
|
+
type: "select",
|
|
50
|
+
options: [
|
|
51
|
+
{ value: "pass-fail", label: { _: msg("Pass/Fail") } },
|
|
52
|
+
{ value: "partial", label: { _: msg("Partial Credit") } },
|
|
53
|
+
],
|
|
54
|
+
label: { _: msg("Grading Method") },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @internal */
|
|
60
|
+
static scopedElements = {
|
|
61
|
+
"sl-tooltip": SlTooltip,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
private static segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
65
|
+
|
|
66
|
+
// Since highlights are document-level, we should share them across all instances of this widget.
|
|
67
|
+
private static highlights: Record<HighlightGroup, Highlight> = {
|
|
68
|
+
marked: new Highlight(),
|
|
69
|
+
correct: new Highlight(),
|
|
70
|
+
missed: new Highlight(),
|
|
71
|
+
wrong: new Highlight(),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
static {
|
|
75
|
+
for (const [group, highlight] of Object.entries(this.highlights)) {
|
|
76
|
+
CSS.highlights.set(`webwriter-mark-${group}`, highlight);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static styles = css`
|
|
81
|
+
:host {
|
|
82
|
+
display: block;
|
|
83
|
+
min-height: 1rem;
|
|
84
|
+
position: relative;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
slot {
|
|
88
|
+
display: block;
|
|
89
|
+
cursor: text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
:host(:not([contenteditable="true"]):not([contenteditable=""])) slot {
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
user-select: none;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
slot[data-empty]::after {
|
|
98
|
+
content: var(--ww-placeholder);
|
|
99
|
+
position: absolute;
|
|
100
|
+
left: 0;
|
|
101
|
+
top: 0;
|
|
102
|
+
color: darkgray;
|
|
103
|
+
pointer-events: none;
|
|
104
|
+
user-select: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
:host ::highlight(webwriter-mark-marked) {
|
|
108
|
+
background-color: var(--sl-color-primary-200);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
:host ::highlight(webwriter-mark-correct) {
|
|
112
|
+
background-color: var(--sl-color-success-200);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
:host ::highlight(webwriter-mark-missed) {
|
|
116
|
+
background-color: var(--sl-color-warning-200);
|
|
117
|
+
text-decoration: underline dashed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
:host ::highlight(webwriter-mark-wrong) {
|
|
121
|
+
background-color: var(--sl-color-danger-200);
|
|
122
|
+
text-decoration: line-through;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.enter-anchor {
|
|
126
|
+
position: absolute;
|
|
127
|
+
}
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* How the score of this answer is calculated.
|
|
132
|
+
*
|
|
133
|
+
* - `pass-fail`: Full score only if exactly the correct words are marked.
|
|
134
|
+
* - `partial`: Correctly marked words earn credit, wrong ones subtract from it.
|
|
135
|
+
*/
|
|
136
|
+
@property({ type: String, attribute: "grading-method", reflect: true })
|
|
137
|
+
accessor gradingMethod: MarkGradingMethod = "pass-fail";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The indices of the word-like segments counting as correct. It is obfuscated in the markup so that learners cannot read it directly.
|
|
141
|
+
*/
|
|
142
|
+
@property({ type: Array, attribute: "solution", reflect: true, converter: encryptedProperty })
|
|
143
|
+
accessor solution: number[] = [];
|
|
144
|
+
|
|
145
|
+
@state() private accessor currentAnswer: number[] = [];
|
|
146
|
+
@state() private accessor graded: boolean = false;
|
|
147
|
+
@state() private accessor detailedFeedback: boolean = false;
|
|
148
|
+
|
|
149
|
+
private renderedRanges: Record<HighlightGroup, Range[]> = {
|
|
150
|
+
marked: [],
|
|
151
|
+
correct: [],
|
|
152
|
+
missed: [],
|
|
153
|
+
wrong: [],
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
private observer = new MutationObserver(records => this.handleMutation(records));
|
|
157
|
+
|
|
158
|
+
private enterTooltipRef = createRef<SlTooltip>();
|
|
159
|
+
private enterAnchorRef = createRef<HTMLDivElement>();
|
|
160
|
+
|
|
161
|
+
protected firstUpdated(changed: PropertyValues): void {
|
|
162
|
+
super.firstUpdated(changed);
|
|
163
|
+
encryptPlaintextAttributes(this);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
connectedCallback(): void {
|
|
167
|
+
super.connectedCallback();
|
|
168
|
+
this.observer.observe(this, { childList: true, characterData: true, subtree: true });
|
|
169
|
+
document.addEventListener("keydown", this.handleKeyDown, { capture: true });
|
|
170
|
+
document.addEventListener("selectionchange", this.handleSelectionChange);
|
|
171
|
+
this.requestUpdate();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
disconnectedCallback(): void {
|
|
175
|
+
super.disconnectedCallback();
|
|
176
|
+
this.observer.disconnect();
|
|
177
|
+
document.removeEventListener("keydown", this.handleKeyDown, { capture: true });
|
|
178
|
+
document.removeEventListener("selectionchange", this.handleSelectionChange);
|
|
179
|
+
for (const group of Object.keys(this.renderedRanges) as HighlightGroup[]) this.setHighlight(group, []);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private getWordSegments(): WordSegment[] {
|
|
183
|
+
const segments: WordSegment[] = [];
|
|
184
|
+
const walker = document.createTreeWalker(this, NodeFilter.SHOW_TEXT);
|
|
185
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
186
|
+
for (const segment of WebWriterMark.segmenter.segment((node as Text).data)) {
|
|
187
|
+
if (segment.isWordLike) {
|
|
188
|
+
segments.push({
|
|
189
|
+
node: node as Text,
|
|
190
|
+
start: segment.index,
|
|
191
|
+
end: segment.index + segment.segment.length,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return segments;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private toRange({ node, start, end }: WordSegment): Range {
|
|
200
|
+
const range = new Range();
|
|
201
|
+
range.setStart(node, start);
|
|
202
|
+
range.setEnd(node, end);
|
|
203
|
+
return range;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private setHighlight(group: HighlightGroup, ranges: Range[]) {
|
|
207
|
+
const highlight = WebWriterMark.highlights[group];
|
|
208
|
+
for (const range of this.renderedRanges[group]) highlight.delete(range);
|
|
209
|
+
for (const range of ranges) highlight.add(range);
|
|
210
|
+
this.renderedRanges[group] = ranges;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private renderHighlights() {
|
|
214
|
+
const segments = this.getWordSegments();
|
|
215
|
+
const toRanges = (indices: number[]) => indices.filter(i => segments[i]).map(i => this.toRange(segments[i]));
|
|
216
|
+
|
|
217
|
+
const showFeedback = this.graded && this.detailedFeedback && !this.isContentEditable;
|
|
218
|
+
const marked = this.isContentEditable ? this.solution : this.currentAnswer;
|
|
219
|
+
const solution = new Set(this.solution);
|
|
220
|
+
const answer = new Set(this.currentAnswer);
|
|
221
|
+
|
|
222
|
+
this.setHighlight("marked", showFeedback ? [] : toRanges(marked));
|
|
223
|
+
this.setHighlight("correct", showFeedback ? toRanges(this.currentAnswer.filter(i => solution.has(i))) : []);
|
|
224
|
+
this.setHighlight("missed", showFeedback ? toRanges(this.solution.filter(i => !answer.has(i))) : []);
|
|
225
|
+
this.setHighlight("wrong", showFeedback ? toRanges(this.currentAnswer.filter(i => !solution.has(i))) : []);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
protected updated(): void {
|
|
229
|
+
this.renderHighlights();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private handleMutation(records: MutationRecord[]) {
|
|
233
|
+
if (this.isContentEditable) {
|
|
234
|
+
const liveRanges = this.renderedRanges.marked.filter(range => !range.collapsed);
|
|
235
|
+
const solution = this.getWordSegments()
|
|
236
|
+
.map((segment, index) => (liveRanges.some(range => rangesOverlap(range, this.toRange(segment))) ? index : -1))
|
|
237
|
+
.filter(index => index !== -1);
|
|
238
|
+
const changed =
|
|
239
|
+
solution.length !== this.solution.length || solution.some((index, i) => index !== this.solution[i]);
|
|
240
|
+
if (changed) this.solution = solution;
|
|
241
|
+
}
|
|
242
|
+
// Redraw the normalized highlight ranges and refresh the placeholder state
|
|
243
|
+
this.requestUpdate();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private toggleIndices(current: number[], indices: number[]): number[] {
|
|
247
|
+
const result = new Set(current);
|
|
248
|
+
for (const index of indices) {
|
|
249
|
+
if (result.has(index)) result.delete(index);
|
|
250
|
+
else result.add(index);
|
|
251
|
+
}
|
|
252
|
+
return [...result].sort((a, b) => a - b);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private toggleSolutionMarking(point: CaretPosition | null): boolean {
|
|
256
|
+
const segments = this.getWordSegments();
|
|
257
|
+
const selection = document.getSelection();
|
|
258
|
+
const selectionRange = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
|
259
|
+
|
|
260
|
+
let indices: number[];
|
|
261
|
+
if (selectionRange && !selectionRange.collapsed && this.contains(selectionRange.commonAncestorContainer)) {
|
|
262
|
+
indices = segments
|
|
263
|
+
.map((segment, index) => (rangesOverlap(selectionRange, this.toRange(segment)) ? index : -1))
|
|
264
|
+
.filter(index => index !== -1);
|
|
265
|
+
} else {
|
|
266
|
+
const node = point ? point.offsetNode : selection?.anchorNode;
|
|
267
|
+
const offset = point ? point.offset : (selection?.anchorOffset ?? 0);
|
|
268
|
+
const index = segments.findIndex(
|
|
269
|
+
segment => segment.node === node && segment.start <= offset && offset <= segment.end,
|
|
270
|
+
);
|
|
271
|
+
indices = index === -1 ? [] : [index];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (indices.length === 0) return false;
|
|
275
|
+
this.solution = this.toggleIndices(this.solution, indices);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** @internal */
|
|
280
|
+
@action({ label: { _: msg("Toggle Marking at Position") } })
|
|
281
|
+
toggleMarkingAtPosition() {
|
|
282
|
+
if (!this.isContentEditable) return;
|
|
283
|
+
this.toggleSolutionMarking(null);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private handleContextMenu(event: MouseEvent) {
|
|
287
|
+
if (!this.isContentEditable) return;
|
|
288
|
+
const point = document.caretPositionFromPoint(event.clientX, event.clientY);
|
|
289
|
+
if (this.toggleSolutionMarking(point)) event.preventDefault();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private handleClick(event: MouseEvent) {
|
|
293
|
+
if (this.isContentEditable || this.graded) return;
|
|
294
|
+
const point = document.caretPositionFromPoint(event.clientX, event.clientY);
|
|
295
|
+
if (!point) return;
|
|
296
|
+
const segments = this.getWordSegments();
|
|
297
|
+
const index = segments.findIndex(
|
|
298
|
+
segment => segment.node === point.offsetNode && segment.start <= point.offset && point.offset <= segment.end,
|
|
299
|
+
);
|
|
300
|
+
if (index === -1) return;
|
|
301
|
+
this.currentAnswer = this.toggleIndices(this.currentAnswer, [index]);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private handleKeyDown = (event: KeyboardEvent) => {
|
|
305
|
+
if (!this.isContentEditable) return;
|
|
306
|
+
const selection = document.getSelection();
|
|
307
|
+
|
|
308
|
+
// As ProseMirror re-creates the DOM on a line break, we cannot support multi-line highlights.
|
|
309
|
+
// Therefore, we show a tooltip when the user presses Enter in the widget to inform them about this limitation.
|
|
310
|
+
if (event.key === "Enter" && selection?.focusNode?.parentElement === this) {
|
|
311
|
+
event.preventDefault();
|
|
312
|
+
const range = document.createRange();
|
|
313
|
+
range.setStart(selection.focusNode, selection.focusOffset); // = cursor position
|
|
314
|
+
range.collapse(true);
|
|
315
|
+
const cursorRect = range.getBoundingClientRect();
|
|
316
|
+
const thisRect = this.shadowRoot!.host.getBoundingClientRect();
|
|
317
|
+
this.enterAnchorRef.value!.style.left = `${cursorRect.left - thisRect.left}px`;
|
|
318
|
+
this.enterAnchorRef.value!.style.top = `${cursorRect.bottom - thisRect.top}px`;
|
|
319
|
+
this.enterTooltipRef.value!.show();
|
|
320
|
+
} else {
|
|
321
|
+
this.enterTooltipRef.value!.hide();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
private handleSelectionChange = () => {
|
|
326
|
+
this.enterTooltipRef.value!.hide();
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
checkValidity(): boolean {
|
|
330
|
+
return this.currentAnswer.length > 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
checkAnswer(detailedFeedback: boolean): number {
|
|
334
|
+
this.graded = true;
|
|
335
|
+
this.detailedFeedback = detailedFeedback;
|
|
336
|
+
|
|
337
|
+
const solution = new Set(this.solution);
|
|
338
|
+
const selected = new Set(this.currentAnswer);
|
|
339
|
+
const selectedCorrect = selected.intersection(solution);
|
|
340
|
+
const selectedWrong = selected.difference(solution);
|
|
341
|
+
|
|
342
|
+
if (solution.size === 0) return 0;
|
|
343
|
+
if (this.gradingMethod === "pass-fail") {
|
|
344
|
+
return selectedWrong.size === 0 && selectedCorrect.size === solution.size ? 1 : 0;
|
|
345
|
+
} else {
|
|
346
|
+
return Math.max(0, selectedCorrect.size - selectedWrong.size) / solution.size;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
reset(): void {
|
|
351
|
+
this.currentAnswer = [];
|
|
352
|
+
this.graded = false;
|
|
353
|
+
this.detailedFeedback = false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
render() {
|
|
357
|
+
return html` <slot
|
|
358
|
+
style=${styleMap({
|
|
359
|
+
"--ww-placeholder": `"${msg("Text to Highlight")}"`,
|
|
360
|
+
})}
|
|
361
|
+
?data-empty=${!this.textContent}
|
|
362
|
+
@click=${this.handleClick}
|
|
363
|
+
@contextmenu=${this.handleContextMenu}
|
|
364
|
+
></slot>
|
|
365
|
+
<sl-tooltip
|
|
366
|
+
${ref(this.enterTooltipRef)}
|
|
367
|
+
content=${msg("Multiple lines are not supported")}
|
|
368
|
+
trigger="manual"
|
|
369
|
+
placement="bottom"
|
|
370
|
+
>
|
|
371
|
+
<div ${ref(this.enterAnchorRef)} class="enter-anchor"></div>
|
|
372
|
+
</sl-tooltip>`;
|
|
373
|
+
}
|
|
374
|
+
}
|