@webwriter/quiz 1.0.4 → 1.0.6

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 (128) hide show
  1. package/LICENSE +6 -6
  2. package/custom.d.ts +175 -175
  3. package/dist/widgets/webwriter-choice-item.css +1 -1
  4. package/dist/widgets/webwriter-choice-item.js +1886 -802
  5. package/dist/widgets/webwriter-choice.css +1 -1
  6. package/dist/widgets/webwriter-choice.js +1943 -846
  7. package/dist/widgets/webwriter-cloze-gap.css +1 -1
  8. package/dist/widgets/webwriter-cloze-gap.js +1988 -902
  9. package/dist/widgets/webwriter-cloze.css +1 -1
  10. package/dist/widgets/webwriter-cloze.js +1833 -748
  11. package/dist/widgets/webwriter-mark.css +1 -1
  12. package/dist/widgets/webwriter-mark.js +1848 -762
  13. package/dist/widgets/webwriter-order-item.css +1 -1
  14. package/dist/widgets/webwriter-order-item.js +1884 -799
  15. package/dist/widgets/webwriter-order.css +1 -1
  16. package/dist/widgets/webwriter-order.js +1887 -2299
  17. package/dist/widgets/webwriter-pairing-item.css +1 -1
  18. package/dist/widgets/webwriter-pairing-item.js +1718 -614
  19. package/dist/widgets/webwriter-pairing.css +1 -1
  20. package/dist/widgets/webwriter-pairing.js +1873 -781
  21. package/dist/widgets/webwriter-quiz.css +1 -1
  22. package/dist/widgets/webwriter-quiz.js +2241 -1026
  23. package/dist/widgets/webwriter-speech.css +1 -1
  24. package/dist/widgets/webwriter-speech.js +2179 -961
  25. package/dist/widgets/webwriter-task-explainer.css +1 -1
  26. package/dist/widgets/webwriter-task-explainer.js +1704 -600
  27. package/dist/widgets/webwriter-task-hint.css +1 -1
  28. package/dist/widgets/webwriter-task-hint.js +1695 -591
  29. package/dist/widgets/webwriter-task-prompt.css +1 -1
  30. package/dist/widgets/webwriter-task-prompt.js +1670 -566
  31. package/dist/widgets/webwriter-task.css +1 -1
  32. package/dist/widgets/webwriter-task.js +2361 -1143
  33. package/dist/widgets/webwriter-text.css +1 -1
  34. package/dist/widgets/webwriter-text.js +1876 -770
  35. package/icon.svg +1 -0
  36. package/lit-localize.json +15 -0
  37. package/localization/bg.xlf +54 -0
  38. package/localization/cs.xlf +54 -0
  39. package/localization/da.xlf +54 -0
  40. package/localization/de.xlf +54 -0
  41. package/localization/el.xlf +54 -0
  42. package/localization/es.xlf +54 -0
  43. package/localization/et.xlf +54 -0
  44. package/localization/fi.xlf +54 -0
  45. package/localization/fr.xlf +54 -0
  46. package/localization/generated/bg.ts +25 -0
  47. package/localization/generated/cs.ts +25 -0
  48. package/localization/generated/da.ts +25 -0
  49. package/localization/generated/de.ts +25 -0
  50. package/localization/generated/el.ts +25 -0
  51. package/localization/generated/es.ts +25 -0
  52. package/localization/generated/et.ts +25 -0
  53. package/localization/generated/fi.ts +25 -0
  54. package/localization/generated/fr.ts +25 -0
  55. package/localization/generated/hu.ts +25 -0
  56. package/localization/generated/id.ts +25 -0
  57. package/localization/generated/index.js +3 -0
  58. package/localization/generated/it.ts +25 -0
  59. package/localization/generated/ja.ts +25 -0
  60. package/localization/generated/ko.ts +25 -0
  61. package/localization/generated/locale-codes.js +79 -0
  62. package/localization/generated/lt.ts +25 -0
  63. package/localization/generated/lv.ts +25 -0
  64. package/localization/generated/nb.ts +25 -0
  65. package/localization/generated/nl.ts +25 -0
  66. package/localization/generated/pl.ts +25 -0
  67. package/localization/generated/pt-BR.ts +25 -0
  68. package/localization/generated/pt-PT.ts +25 -0
  69. package/localization/generated/ro.ts +25 -0
  70. package/localization/generated/ru.ts +25 -0
  71. package/localization/generated/sk.ts +25 -0
  72. package/localization/generated/sl.ts +25 -0
  73. package/localization/generated/sv.ts +25 -0
  74. package/localization/generated/tr.ts +25 -0
  75. package/localization/generated/uk.ts +25 -0
  76. package/localization/generated/zh-hans.ts +25 -0
  77. package/localization/hu.xlf +54 -0
  78. package/localization/id.xlf +54 -0
  79. package/localization/it.xlf +54 -0
  80. package/localization/ja.xlf +54 -0
  81. package/localization/ko.xlf +54 -0
  82. package/localization/lt.xlf +54 -0
  83. package/localization/lv.xlf +54 -0
  84. package/localization/nb.xlf +54 -0
  85. package/localization/nl.xlf +54 -0
  86. package/localization/pl.xlf +54 -0
  87. package/localization/pt-BR.xlf +54 -0
  88. package/localization/pt-PT.xlf +54 -0
  89. package/localization/ro.xlf +54 -0
  90. package/localization/ru.xlf +54 -0
  91. package/localization/sk.xlf +54 -0
  92. package/localization/sl.xlf +54 -0
  93. package/localization/sv.xlf +54 -0
  94. package/localization/tr.xlf +54 -0
  95. package/localization/uk.xlf +54 -0
  96. package/localization/zh-hans.xlf +54 -0
  97. package/package.json +206 -198
  98. package/src/lib/combobox.ts +455 -455
  99. package/src/lib/highlighter-fill.svg +5 -5
  100. package/src/snippets/Beispiel-Choice.html +57 -0
  101. package/src/snippets/Beispiel-Mark.html +14 -0
  102. package/src/snippets/Beispiel-Order.html +52 -0
  103. package/src/snippets/Beispiel-Text.html +20 -0
  104. package/src/snippets/choice.html +7 -7
  105. package/src/snippets/cloze.html +3 -3
  106. package/src/snippets/mark.html +3 -3
  107. package/src/snippets/order.html +7 -7
  108. package/src/snippets/pairing.html +9 -9
  109. package/src/snippets/speech.html +3 -3
  110. package/src/snippets/text.html +3 -3
  111. package/src/snippets/wordsearch.html +3 -3
  112. package/src/widgets/webwriter-choice-item.ts +250 -250
  113. package/src/widgets/webwriter-choice.ts +314 -309
  114. package/src/widgets/webwriter-cloze-gap.ts +215 -216
  115. package/src/widgets/webwriter-cloze.ts +135 -135
  116. package/src/widgets/webwriter-mark.ts +431 -434
  117. package/src/widgets/webwriter-order-item.ts +437 -444
  118. package/src/widgets/webwriter-order.ts +284 -272
  119. package/src/widgets/webwriter-pairing-item.ts +160 -155
  120. package/src/widgets/webwriter-pairing.ts +192 -187
  121. package/src/widgets/webwriter-quiz.ts +281 -280
  122. package/src/widgets/webwriter-speech.ts +272 -267
  123. package/src/widgets/webwriter-task-explainer.ts +42 -37
  124. package/src/widgets/webwriter-task-hint.ts +22 -16
  125. package/src/widgets/webwriter-task-prompt.ts +23 -17
  126. package/src/widgets/webwriter-task.ts +546 -543
  127. package/src/widgets/webwriter-text.ts +135 -130
  128. package/tsconfig.json +6 -6
@@ -1,435 +1,432 @@
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
- /**
10
- * @param {!Node} node
11
- * @param {boolean=} optimized
12
- * @return {string}
13
- */
14
- export const xPath = function (node, optimized=false) {
15
- if (node.nodeType === Node.DOCUMENT_NODE) {
16
- return '/';
17
- }
18
-
19
- const steps = [];
20
- let contextNode = node;
21
- while (contextNode) {
22
- const step = _xPathValue(contextNode, optimized);
23
- if (!step) {
24
- break;
25
- } // Error - bail out early.
26
- steps.push(step);
27
- if (step.optimized) {
28
- break;
29
- }
30
- contextNode = contextNode.parentNode;
31
- }
32
-
33
- steps.reverse();
34
- return (steps.length && steps[0].optimized ? '' : '/') + steps.join('/');
35
- };
36
-
37
- /**
38
- * @param {!Node} node
39
- * @param {boolean=} optimized
40
- * @return {?Step}
41
- */
42
- const _xPathValue = function (node, optimized) {
43
- let ownValue;
44
- const ownIndex = _xPathIndex(node);
45
- if (ownIndex === -1) {
46
- return null;
47
- } // Error.
48
-
49
- switch (node.nodeType) {
50
- case Node.ELEMENT_NODE:
51
- if (optimized && node.getAttribute('id')) {
52
- return new Step('//*[@id="' + node.getAttribute('id') + '"]', true);
53
- }
54
- ownValue = node.localName;
55
- break;
56
- case Node.ATTRIBUTE_NODE:
57
- ownValue = '@' + node.nodeName;
58
- break;
59
- case Node.TEXT_NODE:
60
- case Node.CDATA_SECTION_NODE:
61
- ownValue = 'text()';
62
- break;
63
- case Node.PROCESSING_INSTRUCTION_NODE:
64
- ownValue = 'processing-instruction()';
65
- break;
66
- case Node.COMMENT_NODE:
67
- ownValue = 'comment()';
68
- break;
69
- case Node.DOCUMENT_NODE:
70
- ownValue = '';
71
- break;
72
- default:
73
- ownValue = '';
74
- break;
75
- }
76
-
77
- if (ownIndex > 0) {
78
- ownValue += '[' + ownIndex + ']';
79
- }
80
-
81
- return new Step(ownValue, node.nodeType === Node.DOCUMENT_NODE);
82
- };
83
-
84
- /**
85
- * @param {!Node} node
86
- * @return {number}
87
- */
88
- const _xPathIndex = function (node) {
89
- // Returns -1 in case of error, 0 if no siblings matching the same expression,
90
- // <XPath index among the same expression-matching sibling nodes> otherwise.
91
- function areNodesSimilar(left, right) {
92
- if (left === right) {
93
- return true;
94
- }
95
-
96
- if (left.nodeType === Node.ELEMENT_NODE && right.nodeType === Node.ELEMENT_NODE) {
97
- return left.localName === right.localName;
98
- }
99
-
100
- if (left.nodeType === right.nodeType) {
101
- return true;
102
- }
103
-
104
- // XPath treats CDATA as text nodes.
105
- const leftType = left.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : left.nodeType;
106
- const rightType = right.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : right.nodeType;
107
- return leftType === rightType;
108
- }
109
-
110
- const siblings = node.parentNode ? node.parentNode.children : null;
111
- if (!siblings) {
112
- return 0;
113
- } // Root node - no siblings.
114
- let hasSameNamedElements;
115
- for (let i = 0; i < siblings.length; ++i) {
116
- if (areNodesSimilar(node, siblings[i]) && siblings[i] !== node) {
117
- hasSameNamedElements = true;
118
- break;
119
- }
120
- }
121
- if (!hasSameNamedElements) {
122
- return 0;
123
- }
124
- let ownIndex = 1; // XPath indices start with 1.
125
- for (let i = 0; i < siblings.length; ++i) {
126
- if (areNodesSimilar(node, siblings[i])) {
127
- if (siblings[i] === node) {
128
- return ownIndex;
129
- }
130
- ++ownIndex;
131
- }
132
- }
133
- return -1; // An error occurred: |node| not found in parent's children.
134
- };
135
-
136
- /**
137
- * @unrestricted
138
- */
139
- const Step = class {
140
- constructor(readonly value: string, readonly optimized=false) {}
141
-
142
- toString() {
143
- return this.value;
144
- }
145
- };
146
-
147
- import IconHighlighter from "bootstrap-icons/icons/highlighter.svg"
148
- import IconHighlighterFill from "../lib/highlighter-fill.svg"
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
- static shadowRootOptions = {...LitElementWw.shadowRootOptions, delegatesFocus: false}
229
-
230
- static localization = {}
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
- msg = (str: string) => this.lang in WebwriterMark.localization? WebwriterMark.localization[this.lang][str] ?? str: str
246
-
247
-
248
- static scopedElements = {
249
- "sl-icon-button": SlIconButton
250
- }
251
-
252
- static styles = css`
253
- :host {
254
- min-height: 1rem;
255
- position: relative;
256
- }
257
-
258
- slot {
259
- display: block;
260
- cursor: text;
261
- }
262
-
263
- :host(:not([contenteditable=true]):not([contenteditable=""])) slot {
264
- cursor: pointer;
265
- user-select: none;
266
- }
267
-
268
- #highlight {
269
- position: absolute;
270
- right: 0;
271
- top: 0;
272
- background: rgba(255, 255, 255, 0.85)
273
- }
274
-
275
- slot[data-empty]:after {
276
- content: var(--ww-placeholder);
277
- position: absolute;
278
- left: 0;
279
- top: 0;
280
- color: darkgray;
281
- pointer-events: none;
282
- user-select: none;
283
- }
284
-
285
- #highlight::part(base):hover {
286
- background: yellow;
287
- }
288
-
289
- :host ::highlight(webwriter-mark-value) {
290
- background-color: yellow;
291
- }
292
-
293
- :host ::highlight(webwriter-mark-solution) {
294
- background-color: greenyellow;
295
- }
296
-
297
- #highlight[data-highlighting]::part(base) {
298
- background-color: lightyellow;
299
- }
300
-
301
- :host(:has(#highlight[data-highlighting])) ::selection {
302
- background: lightyellow !important;
303
- }
304
-
305
- :host([highlighting]) #highlight::part(base) {
306
- background: yellow;
307
- }
308
- `
309
-
310
- @property({type: Boolean, attribute: true, reflect: true})
311
- accessor highlighting = false
312
-
313
- #solution: SerializableRange[] = []
314
-
315
- get solution(): SerializableRange[] {
316
- return this.#solution
317
- }
318
-
319
- @property({attribute: false})
320
- set solution(value: SerializableRangeLike[]) {
321
- this.#updateHighlight("solution", value.map(v => new SerializableRange(v)))
322
- this.requestUpdate("solution")
323
- }
324
-
325
- #value: SerializableRange[] = []
326
-
327
- get value(): SerializableRange[] {
328
- return this.#value
329
- }
330
-
331
- @property({attribute: true, reflect: true, converter: {toAttribute: toAttributeRange, fromAttribute: fromAttributeRange}})
332
- set value(value: SerializableRangeLike[]) {
333
- this.#updateHighlight("value", value.map(v => new SerializableRange(v)))
334
- this.requestUpdate("value")
335
- }
336
-
337
- #updateHighlight(key: "value" | "solution", value: SerializableRange[]) {
338
- const prev = this[key]
339
- const finalValue = value.filter(range => {
340
- const isContained = value.some(otherRange => {
341
- if(range === otherRange) {
342
- return
343
- }
344
- const startsWithin = range.compareBoundaryPoints(Range.START_TO_START, otherRange) > -1
345
- const endsWithin = range.compareBoundaryPoints(Range.END_TO_END, otherRange) <= 0
346
- return startsWithin && endsWithin
347
- })
348
- return !isContained && !range.collapsed
349
- })
350
- if(key === "value") {
351
- this.#value = finalValue
352
- }
353
- else {
354
- this.#solution = finalValue
355
- }
356
- const highlight = WebwriterMark[key === "value"? "highlightValue": "highlightSolution"]
357
- for(const range of prev) {
358
- highlight.delete(range)
359
- }
360
- for(const range of value) {
361
- highlight.add(range)
362
- }
363
- }
364
-
365
- observer: MutationObserver
366
-
367
- segments: Intl.SegmentData[] = []
368
-
369
- connectedCallback(): void {
370
- super.connectedCallback()
371
- const segmenter = new Intl.Segmenter(undefined, {granularity: "word"})
372
- this.segments = [...segmenter.segment(this.textContent)]
373
- this.observer = new MutationObserver(() => {
374
- this.value = this.value
375
- this.segments = [...segmenter.segment(this.textContent)]
376
- })
377
- this.observer.observe(this, {characterData: true, childList: true, subtree: true})
378
- }
379
-
380
- disconnectedCallback(): void {
381
- super.disconnectedCallback()
382
- this.observer?.disconnect()
383
- }
384
-
385
- @eventOptions({passive: true})
386
- @action({label: {_: "Toggle Highlight"}})
387
- handleHighlight(e?: PointerEvent) {
388
- if(e && this.isContentEditable && e.type === "click") {
389
- return
390
- }
391
- else if(e && !this.isContentEditable && e.type === "contextmenu") {
392
- return
393
- }
394
- // convert click to caret position
395
-
396
- const {textNode, offset} = e
397
- ? getCaretPositionFromPoint(e)
398
- : {textNode: document.getSelection().anchorNode, offset: document.getSelection().anchorOffset}
399
- // convert caret position to segment range
400
- const segment = this.segments.filter(seg => seg.isWordLike).find(({index, segment}) => index <= offset && offset <= index + segment.length)
401
- // add or remove segment range from highlights
402
- if(segment) {
403
- const range = new SerializableRange()
404
- const start = segment.index
405
- const end = segment.index + segment.segment.length
406
- range.setStart(textNode, start)
407
- range.setEnd(textNode, end)
408
- const key = this.isContentEditable? "solution": "value"
409
- const sameRange = this[key].find(r => r.startContainer === textNode && r.endContainer === textNode && r.startOffset === start && r.endOffset == end)
410
- if(sameRange) {
411
- this[key] = this[key].filter(r => r !== sameRange)
412
- }
413
- else {
414
- this[key] = [...this[key], range]
415
- }
416
- this.dispatchEvent(new CustomEvent("ww-answer-change", {
417
- bubbles: true,
418
- composed: true
419
- }))
420
- }
421
- }
422
-
423
- reset() {
424
- this.value = this.solution = []
425
- }
426
-
427
- reportSolution() {}
428
-
429
-
430
- render() {
431
- return html`
432
- <slot style=${styleMap({"--ww-placeholder": `"${this.msg("Text to Highlight")}"`})} ?data-empty=${!this.textContent} @click=${this.handleHighlight} @contextmenu=${this.handleHighlight}></slot>
433
- `
434
- }
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
+ }
435
432
  }