@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
@@ -1,432 +1,374 @@
1
- import {html, css, RenderOptions} from "lit"
2
- import {action, LitElementWw} from "@webwriter/lit"
3
- import {customElement, eventOptions, property} from "lit/decorators.js"
4
- import {styleMap} from "lit/directives/style-map.js"
5
- import "@shoelace-style/shoelace/dist/themes/light.css"
6
-
7
- import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js"
8
-
9
- import LOCALIZE from "../../localization/generated"
10
- import {msg} from "@lit/localize"
11
-
12
- /**
13
- * @param {!Node} node
14
- * @param {boolean=} optimized
15
- * @return {string}
16
- */
17
- export const xPath = function (node, optimized=false) {
18
- if (node.nodeType === Node.DOCUMENT_NODE) {
19
- return '/';
20
- }
21
-
22
- const steps = [];
23
- let contextNode = node;
24
- while (contextNode) {
25
- const step = _xPathValue(contextNode, optimized);
26
- if (!step) {
27
- break;
28
- } // Error - bail out early.
29
- steps.push(step);
30
- if (step.optimized) {
31
- break;
32
- }
33
- contextNode = contextNode.parentNode;
34
- }
35
-
36
- steps.reverse();
37
- return (steps.length && steps[0].optimized ? '' : '/') + steps.join('/');
38
- };
39
-
40
- /**
41
- * @param {!Node} node
42
- * @param {boolean=} optimized
43
- * @return {?Step}
44
- */
45
- const _xPathValue = function (node, optimized) {
46
- let ownValue;
47
- const ownIndex = _xPathIndex(node);
48
- if (ownIndex === -1) {
49
- return null;
50
- } // Error.
51
-
52
- switch (node.nodeType) {
53
- case Node.ELEMENT_NODE:
54
- if (optimized && node.getAttribute('id')) {
55
- return new Step('//*[@id="' + node.getAttribute('id') + '"]', true);
56
- }
57
- ownValue = node.localName;
58
- break;
59
- case Node.ATTRIBUTE_NODE:
60
- ownValue = '@' + node.nodeName;
61
- break;
62
- case Node.TEXT_NODE:
63
- case Node.CDATA_SECTION_NODE:
64
- ownValue = 'text()';
65
- break;
66
- case Node.PROCESSING_INSTRUCTION_NODE:
67
- ownValue = 'processing-instruction()';
68
- break;
69
- case Node.COMMENT_NODE:
70
- ownValue = 'comment()';
71
- break;
72
- case Node.DOCUMENT_NODE:
73
- ownValue = '';
74
- break;
75
- default:
76
- ownValue = '';
77
- break;
78
- }
79
-
80
- if (ownIndex > 0) {
81
- ownValue += '[' + ownIndex + ']';
82
- }
83
-
84
- return new Step(ownValue, node.nodeType === Node.DOCUMENT_NODE);
85
- };
86
-
87
- /**
88
- * @param {!Node} node
89
- * @return {number}
90
- */
91
- const _xPathIndex = function (node) {
92
- // Returns -1 in case of error, 0 if no siblings matching the same expression,
93
- // <XPath index among the same expression-matching sibling nodes> otherwise.
94
- function areNodesSimilar(left, right) {
95
- if (left === right) {
96
- return true;
97
- }
98
-
99
- if (left.nodeType === Node.ELEMENT_NODE && right.nodeType === Node.ELEMENT_NODE) {
100
- return left.localName === right.localName;
101
- }
102
-
103
- if (left.nodeType === right.nodeType) {
104
- return true;
105
- }
106
-
107
- // XPath treats CDATA as text nodes.
108
- const leftType = left.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : left.nodeType;
109
- const rightType = right.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : right.nodeType;
110
- return leftType === rightType;
111
- }
112
-
113
- const siblings = node.parentNode ? node.parentNode.children : null;
114
- if (!siblings) {
115
- return 0;
116
- } // Root node - no siblings.
117
- let hasSameNamedElements;
118
- for (let i = 0; i < siblings.length; ++i) {
119
- if (areNodesSimilar(node, siblings[i]) && siblings[i] !== node) {
120
- hasSameNamedElements = true;
121
- break;
122
- }
123
- }
124
- if (!hasSameNamedElements) {
125
- return 0;
126
- }
127
- let ownIndex = 1; // XPath indices start with 1.
128
- for (let i = 0; i < siblings.length; ++i) {
129
- if (areNodesSimilar(node, siblings[i])) {
130
- if (siblings[i] === node) {
131
- return ownIndex;
132
- }
133
- ++ownIndex;
134
- }
135
- }
136
- return -1; // An error occurred: |node| not found in parent's children.
137
- };
138
-
139
- /**
140
- * @unrestricted
141
- */
142
- const Step = class {
143
- constructor(readonly value: string, readonly optimized=false) {}
144
-
145
- toString() {
146
- return this.value;
147
- }
148
- };
149
-
150
- function getCaretPositionFromPoint(e: PointerEvent) {
151
- let range: Range | null;
152
- let textNode: Text;
153
- let offset: number;
154
-
155
- if ((document as any).caretPositionFromPoint) {
156
- range = (document as any).caretPositionFromPoint(e.clientX, e.clientY);
157
- textNode = (range as any).offsetNode;
158
- offset = (range as any).offset;
159
- return {textNode, offset}
160
- } else if (document.caretRangeFromPoint) {
161
- // Use WebKit-proprietary fallback method
162
- range = document.caretRangeFromPoint(e.clientX, e.clientY);
163
- textNode = range.startContainer as Text;
164
- offset = range.startOffset;
165
- return {textNode, offset}
166
- } else {
167
- throw Error("Both 'caretPositionFromPoint' and 'caretRangeFromPoint' are unsupported")
168
- }
169
- }
170
-
171
- const toAttributeRange = (ranges: SerializableRange[]) => {
172
- return JSON.stringify(ranges)
173
- }
174
-
175
- const fromAttributeRange = (attr?: string) => {
176
- if(!attr) {
177
- return []
178
- }
179
- const ranges = JSON.parse(attr) as {startContainer: string, startOffset: number, endContainer?: string, endOffset: number}[]
180
- return ranges.map(range => new SerializableRange(range))
181
- }
182
-
183
- type SerializableRangeLike = SerializableRange | {startContainer: string, startOffset: number, endContainer?: string, endOffset: number}
184
-
185
- class SerializableRange extends Range {
186
-
187
- constructor(value?: string | SerializableRangeLike) {
188
- super()
189
- if(value instanceof SerializableRange) {
190
- return value
191
- }
192
- else if(value) {
193
- const {startContainer, startOffset, endContainer, endOffset} = typeof value === "string"? JSON.parse(value) as {startContainer: string, startOffset: number, endContainer?: string, endOffset: number}: value
194
- const startNode = document.evaluate(startContainer, document, null, 9, null).singleNodeValue
195
- startNode && this.setStart(startNode, startOffset)
196
- const endNode = document.evaluate(endContainer ?? startContainer, document, null, 9, null).singleNodeValue
197
- endNode && this.setEnd(endNode, endOffset)
198
- }
199
- }
200
-
201
- toJSON() {
202
- return this.startContainer === this.endContainer
203
- ? {
204
- startContainer: xPath(this.startContainer),
205
- startOffset: this.startOffset,
206
- endOffset: this.endOffset
207
- }
208
- : {
209
- startContainer: xPath(this.startContainer),
210
- startOffset: this.startOffset,
211
- endContainer: xPath(this.endContainer),
212
- endOffset: this.endOffset
213
- }
214
- }
215
-
216
- toString() {
217
- return JSON.stringify(this.toJSON())
218
- }
219
- }
220
-
221
- declare global {interface HTMLElementTagNameMap {
222
- "webwriter-mark": WebwriterMark;
223
- }}
224
-
225
- @customElement("webwriter-mark")
226
- export class WebwriterMark extends LitElementWw {
227
-
228
- localize = LOCALIZE
229
-
230
- static shadowRootOptions = {...LitElementWw.shadowRootOptions, delegatesFocus: false}
231
-
232
- // @ts-ignore: Experimental API
233
- static highlightValue = new Highlight()
234
-
235
- // @ts-ignore: Experimental API
236
- static highlightSolution = new Highlight()
237
-
238
- static {
239
- // @ts-ignore: Experimental API
240
- CSS.highlights.set("webwriter-mark-solution", this.highlightSolution)
241
- // @ts-ignore: Experimental API
242
- CSS.highlights.set("webwriter-mark-value", this.highlightValue)
243
- }
244
-
245
- static scopedElements = {
246
- "sl-icon-button": SlIconButton
247
- }
248
-
249
- static styles = css`
250
- :host {
251
- min-height: 1rem;
252
- position: relative;
253
- }
254
-
255
- slot {
256
- display: block;
257
- cursor: text;
258
- }
259
-
260
- :host(:not([contenteditable=true]):not([contenteditable=""])) slot {
261
- cursor: pointer;
262
- user-select: none;
263
- }
264
-
265
- #highlight {
266
- position: absolute;
267
- right: 0;
268
- top: 0;
269
- background: rgba(255, 255, 255, 0.85)
270
- }
271
-
272
- slot[data-empty]:after {
273
- content: var(--ww-placeholder);
274
- position: absolute;
275
- left: 0;
276
- top: 0;
277
- color: darkgray;
278
- pointer-events: none;
279
- user-select: none;
280
- }
281
-
282
- #highlight::part(base):hover {
283
- background: yellow;
284
- }
285
-
286
- :host ::highlight(webwriter-mark-value) {
287
- background-color: yellow;
288
- }
289
-
290
- :host ::highlight(webwriter-mark-solution) {
291
- background-color: greenyellow;
292
- }
293
-
294
- #highlight[data-highlighting]::part(base) {
295
- background-color: lightyellow;
296
- }
297
-
298
- :host(:has(#highlight[data-highlighting])) ::selection {
299
- background: lightyellow !important;
300
- }
301
-
302
- :host([highlighting]) #highlight::part(base) {
303
- background: yellow;
304
- }
305
- `
306
-
307
- @property({type: Boolean, attribute: true, reflect: true})
308
- accessor highlighting = false
309
-
310
- #solution: SerializableRange[] = []
311
-
312
- get solution(): SerializableRange[] {
313
- return this.#solution
314
- }
315
-
316
- @property({attribute: false})
317
- set solution(value: SerializableRangeLike[]) {
318
- this.#updateHighlight("solution", value.map(v => new SerializableRange(v)))
319
- this.requestUpdate("solution")
320
- }
321
-
322
- #value: SerializableRange[] = []
323
-
324
- get value(): SerializableRange[] {
325
- return this.#value
326
- }
327
-
328
- @property({attribute: true, reflect: true, converter: {toAttribute: toAttributeRange, fromAttribute: fromAttributeRange}})
329
- set value(value: SerializableRangeLike[]) {
330
- this.#updateHighlight("value", value.map(v => new SerializableRange(v)))
331
- this.requestUpdate("value")
332
- }
333
-
334
- #updateHighlight(key: "value" | "solution", value: SerializableRange[]) {
335
- const prev = this[key]
336
- const finalValue = value.filter(range => {
337
- const isContained = value.some(otherRange => {
338
- if(range === otherRange) {
339
- return
340
- }
341
- const startsWithin = range.compareBoundaryPoints(Range.START_TO_START, otherRange) > -1
342
- const endsWithin = range.compareBoundaryPoints(Range.END_TO_END, otherRange) <= 0
343
- return startsWithin && endsWithin
344
- })
345
- return !isContained && !range.collapsed
346
- })
347
- if(key === "value") {
348
- this.#value = finalValue
349
- }
350
- else {
351
- this.#solution = finalValue
352
- }
353
- const highlight = WebwriterMark[key === "value"? "highlightValue": "highlightSolution"]
354
- for(const range of prev) {
355
- highlight.delete(range)
356
- }
357
- for(const range of value) {
358
- highlight.add(range)
359
- }
360
- }
361
-
362
- observer: MutationObserver
363
-
364
- segments: Intl.SegmentData[] = []
365
-
366
- connectedCallback(): void {
367
- super.connectedCallback()
368
- const segmenter = new Intl.Segmenter(undefined, {granularity: "word"})
369
- this.segments = [...segmenter.segment(this.textContent)]
370
- this.observer = new MutationObserver(() => {
371
- this.value = this.value
372
- this.segments = [...segmenter.segment(this.textContent)]
373
- })
374
- this.observer.observe(this, {characterData: true, childList: true, subtree: true})
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
+ }