@webwriter/quiz 1.1.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +778 -0
  3. package/assets/icons/grip-horizontal.svg +8 -0
  4. package/assets/icons/grip-vertical.svg +8 -0
  5. package/assets/icons/truefalse.svg +6 -0
  6. package/custom-elements.json +2779 -0
  7. package/custom.d.ts +5 -1
  8. package/dist/api/index.d.ts +27 -0
  9. package/dist/api/index.js +40 -0
  10. package/dist/migrate.js +1434 -0
  11. package/dist/widgets/webwriter-choice-item.js +4095 -3818
  12. package/dist/widgets/webwriter-choice.js +2179 -5598
  13. package/dist/widgets/webwriter-gap-item.js +3987 -0
  14. package/dist/widgets/webwriter-gap.js +2908 -0
  15. package/dist/widgets/webwriter-mark.js +4347 -3368
  16. package/dist/widgets/webwriter-order-item.js +4038 -3197
  17. package/dist/widgets/webwriter-order.js +2885 -4200
  18. package/dist/widgets/webwriter-pairing-item.js +1070 -2330
  19. package/dist/widgets/webwriter-pairing.js +2968 -4231
  20. package/dist/widgets/webwriter-quiz-hint.js +2469 -0
  21. package/dist/widgets/webwriter-quiz.js +3337 -4364
  22. package/dist/widgets/webwriter-speech.js +6100 -5120
  23. package/dist/widgets/webwriter-task-prompt.js +4787 -2068
  24. package/dist/widgets/webwriter-task.js +2264 -8157
  25. package/dist/widgets/webwriter-text.js +4075 -4132
  26. package/dist/widgets/webwriter-true-false.js +4674 -0
  27. package/editing-config.json +106 -0
  28. package/lit-localize.json +20 -14
  29. package/localization/de.xlf +395 -65
  30. package/localization/es.xlf +396 -61
  31. package/localization/fr.xlf +396 -61
  32. package/localization/generated/de.ts +97 -15
  33. package/localization/generated/es.ts +97 -15
  34. package/localization/generated/fr.ts +98 -16
  35. package/localization/generated/index.d.ts +5 -0
  36. package/localization/generated/it.ts +96 -14
  37. package/localization/generated/locale-codes.js +0 -48
  38. package/localization/generated/nl.ts +99 -17
  39. package/localization/it.xlf +396 -61
  40. package/localization/nl.xlf +396 -61
  41. package/package.json +120 -254
  42. package/scripts/apply-translations.mjs +97 -0
  43. package/src/api/index.ts +72 -0
  44. package/src/lib/artboard.ts +686 -0
  45. package/src/lib/draggable-grid.ts +432 -0
  46. package/src/lib/encrypted-property.ts +202 -0
  47. package/src/lib/hint-popup.ts +152 -0
  48. package/src/lib/utils.ts +97 -0
  49. package/src/lib/webwriter-quirks.ts +121 -0
  50. package/src/migrate/index.ts +622 -0
  51. package/src/migrate/legacy-solution.ts +362 -0
  52. package/src/snippets/order.html +8 -7
  53. package/src/widgets/choice/webwriter-choice-item.ts +316 -0
  54. package/src/widgets/choice/webwriter-choice.ts +328 -0
  55. package/src/widgets/choice/webwriter-true-false.ts +113 -0
  56. package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
  57. package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
  58. package/src/widgets/dnd/webwriter-dnd.ts +36 -0
  59. package/src/widgets/gap/webwriter-gap-item.ts +250 -0
  60. package/src/widgets/gap/webwriter-gap.ts +447 -0
  61. package/src/widgets/order/webwriter-order-item.ts +243 -0
  62. package/src/widgets/order/webwriter-order.ts +282 -0
  63. package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
  64. package/src/widgets/pairing/webwriter-pairing.ts +934 -0
  65. package/src/widgets/speech/audio-player.ts +325 -0
  66. package/src/widgets/speech/audio-recorder.ts +285 -0
  67. package/src/widgets/speech/waveform.ts +113 -0
  68. package/src/widgets/speech/webwriter-speech.ts +87 -0
  69. package/src/widgets/webwriter-mark.ts +366 -424
  70. package/src/widgets/webwriter-quiz-hint.ts +36 -0
  71. package/src/widgets/webwriter-quiz.ts +318 -280
  72. package/src/widgets/webwriter-task-prompt.ts +43 -18
  73. package/src/widgets/webwriter-task.ts +212 -551
  74. package/src/widgets/webwriter-text.ts +286 -172
  75. package/tsconfig.api.json +12 -0
  76. package/tsconfig.json +7 -6
  77. package/dist/widgets/webwriter-choice.css +0 -444
  78. package/dist/widgets/webwriter-cloze-gap.css +0 -444
  79. package/dist/widgets/webwriter-cloze-gap.js +0 -5727
  80. package/dist/widgets/webwriter-cloze.css +0 -444
  81. package/dist/widgets/webwriter-cloze.js +0 -3653
  82. package/dist/widgets/webwriter-mark.css +0 -444
  83. package/dist/widgets/webwriter-order.css +0 -444
  84. package/dist/widgets/webwriter-pairing-item.css +0 -444
  85. package/dist/widgets/webwriter-pairing.css +0 -444
  86. package/dist/widgets/webwriter-quiz.css +0 -444
  87. package/dist/widgets/webwriter-task-explainer.css +0 -444
  88. package/dist/widgets/webwriter-task-explainer.js +0 -2769
  89. package/dist/widgets/webwriter-task-hint.css +0 -444
  90. package/dist/widgets/webwriter-task-hint.js +0 -2708
  91. package/dist/widgets/webwriter-task.css +0 -444
  92. package/dist/widgets/webwriter-text.css +0 -444
  93. package/localization/bg.xlf +0 -72
  94. package/localization/cs.xlf +0 -72
  95. package/localization/da.xlf +0 -72
  96. package/localization/el.xlf +0 -72
  97. package/localization/et.xlf +0 -72
  98. package/localization/fi.xlf +0 -72
  99. package/localization/generated/bg.ts +0 -31
  100. package/localization/generated/cs.ts +0 -31
  101. package/localization/generated/da.ts +0 -31
  102. package/localization/generated/el.ts +0 -31
  103. package/localization/generated/et.ts +0 -31
  104. package/localization/generated/fi.ts +0 -31
  105. package/localization/generated/hu.ts +0 -31
  106. package/localization/generated/id.ts +0 -31
  107. package/localization/generated/ja.ts +0 -31
  108. package/localization/generated/ko.ts +0 -31
  109. package/localization/generated/lt.ts +0 -31
  110. package/localization/generated/lv.ts +0 -31
  111. package/localization/generated/nb.ts +0 -31
  112. package/localization/generated/pl.ts +0 -31
  113. package/localization/generated/pt-BR.ts +0 -31
  114. package/localization/generated/pt-PT.ts +0 -31
  115. package/localization/generated/ro.ts +0 -31
  116. package/localization/generated/ru.ts +0 -31
  117. package/localization/generated/sk.ts +0 -31
  118. package/localization/generated/sl.ts +0 -31
  119. package/localization/generated/sv.ts +0 -31
  120. package/localization/generated/tr.ts +0 -31
  121. package/localization/generated/uk.ts +0 -31
  122. package/localization/generated/zh-hans.ts +0 -31
  123. package/localization/hu.xlf +0 -72
  124. package/localization/id.xlf +0 -72
  125. package/localization/ja.xlf +0 -72
  126. package/localization/ko.xlf +0 -72
  127. package/localization/lt.xlf +0 -72
  128. package/localization/lv.xlf +0 -72
  129. package/localization/nb.xlf +0 -72
  130. package/localization/pl.xlf +0 -72
  131. package/localization/pt-BR.xlf +0 -72
  132. package/localization/pt-PT.xlf +0 -72
  133. package/localization/ro.xlf +0 -72
  134. package/localization/ru.xlf +0 -72
  135. package/localization/sk.xlf +0 -72
  136. package/localization/sl.xlf +0 -72
  137. package/localization/sv.xlf +0 -72
  138. package/localization/tr.xlf +0 -72
  139. package/localization/uk.xlf +0 -72
  140. package/localization/zh-hans.xlf +0 -72
  141. package/src/lib/combobox.ts +0 -456
  142. package/src/lib/highlighter-fill.svg +0 -6
  143. package/src/snippets/Beispiel-Choice.html +0 -57
  144. package/src/snippets/Beispiel-Mark.html +0 -14
  145. package/src/snippets/Beispiel-Order.html +0 -52
  146. package/src/snippets/Beispiel-Text.html +0 -20
  147. package/src/snippets/blank.html +0 -1
  148. package/src/snippets/choice.html +0 -8
  149. package/src/snippets/cloze.html +0 -4
  150. package/src/snippets/mark.html +0 -4
  151. package/src/snippets/pairing.html +0 -10
  152. package/src/snippets/speech.html +0 -4
  153. package/src/snippets/text.html +0 -4
  154. package/src/snippets/wordsearch.html +0 -4
  155. package/src/widgets/webwriter-choice-item.ts +0 -257
  156. package/src/widgets/webwriter-choice.ts +0 -353
  157. package/src/widgets/webwriter-cloze-gap.ts +0 -216
  158. package/src/widgets/webwriter-cloze.ts +0 -136
  159. package/src/widgets/webwriter-order-item.ts +0 -441
  160. package/src/widgets/webwriter-order.ts +0 -327
  161. package/src/widgets/webwriter-pairing-item.ts +0 -161
  162. package/src/widgets/webwriter-pairing.ts +0 -193
  163. package/src/widgets/webwriter-speech.ts +0 -273
  164. package/src/widgets/webwriter-task-explainer.ts +0 -43
  165. package/src/widgets/webwriter-task-hint.ts +0 -23
@@ -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"
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
+ }
6
18
 
7
- import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js"
19
+ export type MarkGradingMethod = "pass-fail" | "partial";
8
20
 
9
- import LOCALIZE from "../../localization/generated"
10
- import {msg} from "@lit/localize"
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
+ });
11
29
 
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
- };
30
+ type WordSegment = { node: Text; start: number; end: number };
31
+ type HighlightGroup = "marked" | "correct" | "missed" | "wrong";
86
32
 
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
- };
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
+ }
138
36
 
139
37
  /**
140
- * @unrestricted
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>`.
141
42
  */
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
43
  @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: #79b521;
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
- }
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
+ }