@webwriter/quiz 1.0.10 → 1.1.2

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 (104) hide show
  1. package/LICENSE +6 -6
  2. package/custom.d.ts +175 -175
  3. package/dist/widgets/webwriter-choice-item.js +10 -7
  4. package/dist/widgets/webwriter-choice.js +36 -6
  5. package/dist/widgets/webwriter-order-item.js +14 -12
  6. package/dist/widgets/webwriter-order.js +28 -4
  7. package/dist/widgets/webwriter-quiz.js +1 -1
  8. package/dist/widgets/webwriter-task.js +0 -1
  9. package/dist/widgets/webwriter-text.js +26 -9
  10. package/lit-localize.json +14 -14
  11. package/localization/bg.xlf +69 -69
  12. package/localization/cs.xlf +69 -69
  13. package/localization/da.xlf +69 -69
  14. package/localization/de.xlf +74 -74
  15. package/localization/el.xlf +69 -69
  16. package/localization/es.xlf +69 -69
  17. package/localization/et.xlf +69 -69
  18. package/localization/fi.xlf +69 -69
  19. package/localization/fr.xlf +69 -69
  20. package/localization/generated/bg.ts +29 -29
  21. package/localization/generated/cs.ts +29 -29
  22. package/localization/generated/da.ts +29 -29
  23. package/localization/generated/de.ts +29 -29
  24. package/localization/generated/el.ts +29 -29
  25. package/localization/generated/es.ts +29 -29
  26. package/localization/generated/et.ts +29 -29
  27. package/localization/generated/fi.ts +29 -29
  28. package/localization/generated/fr.ts +29 -29
  29. package/localization/generated/hu.ts +29 -29
  30. package/localization/generated/id.ts +29 -29
  31. package/localization/generated/index.js +2 -2
  32. package/localization/generated/it.ts +29 -29
  33. package/localization/generated/ja.ts +29 -29
  34. package/localization/generated/ko.ts +29 -29
  35. package/localization/generated/locale-codes.js +79 -79
  36. package/localization/generated/lt.ts +29 -29
  37. package/localization/generated/lv.ts +29 -29
  38. package/localization/generated/nb.ts +29 -29
  39. package/localization/generated/nl.ts +29 -29
  40. package/localization/generated/pl.ts +29 -29
  41. package/localization/generated/pt-BR.ts +29 -29
  42. package/localization/generated/pt-PT.ts +29 -29
  43. package/localization/generated/ro.ts +29 -29
  44. package/localization/generated/ru.ts +29 -29
  45. package/localization/generated/sk.ts +29 -29
  46. package/localization/generated/sl.ts +29 -29
  47. package/localization/generated/sv.ts +29 -29
  48. package/localization/generated/tr.ts +29 -29
  49. package/localization/generated/uk.ts +29 -29
  50. package/localization/generated/zh-hans.ts +29 -29
  51. package/localization/hu.xlf +69 -69
  52. package/localization/id.xlf +69 -69
  53. package/localization/it.xlf +69 -69
  54. package/localization/ja.xlf +69 -69
  55. package/localization/ko.xlf +69 -69
  56. package/localization/lt.xlf +69 -69
  57. package/localization/lv.xlf +69 -69
  58. package/localization/nb.xlf +69 -69
  59. package/localization/nl.xlf +69 -69
  60. package/localization/pl.xlf +69 -69
  61. package/localization/pt-BR.xlf +69 -69
  62. package/localization/pt-PT.xlf +69 -69
  63. package/localization/ro.xlf +69 -69
  64. package/localization/ru.xlf +69 -69
  65. package/localization/sk.xlf +69 -69
  66. package/localization/sl.xlf +69 -69
  67. package/localization/sv.xlf +69 -69
  68. package/localization/tr.xlf +69 -69
  69. package/localization/uk.xlf +69 -69
  70. package/localization/zh-hans.xlf +69 -69
  71. package/package.json +7 -2
  72. package/src/lib/combobox.ts +455 -455
  73. package/src/lib/highlighter-fill.svg +5 -5
  74. package/src/snippets/Beispiel-Choice.html +57 -57
  75. package/src/snippets/Beispiel-Mark.html +13 -13
  76. package/src/snippets/Beispiel-Order.html +52 -52
  77. package/src/snippets/Beispiel-Text.html +20 -20
  78. package/src/snippets/blank.html +1 -0
  79. package/src/snippets/choice.html +7 -7
  80. package/src/snippets/cloze.html +3 -3
  81. package/src/snippets/mark.html +3 -3
  82. package/src/snippets/order.html +7 -7
  83. package/src/snippets/pairing.html +9 -9
  84. package/src/snippets/speech.html +3 -3
  85. package/src/snippets/text.html +3 -3
  86. package/src/snippets/wordsearch.html +3 -3
  87. package/src/widgets/webwriter-choice-item.ts +253 -250
  88. package/src/widgets/webwriter-choice.ts +336 -314
  89. package/src/widgets/webwriter-cloze-gap.ts +215 -215
  90. package/src/widgets/webwriter-cloze.ts +135 -135
  91. package/src/widgets/webwriter-mark.ts +431 -431
  92. package/src/widgets/webwriter-order-item.ts +440 -437
  93. package/src/widgets/webwriter-order.ts +309 -284
  94. package/src/widgets/webwriter-pairing-item.ts +160 -160
  95. package/src/widgets/webwriter-pairing.ts +192 -192
  96. package/src/widgets/webwriter-quiz.ts +281 -281
  97. package/src/widgets/webwriter-speech.ts +272 -272
  98. package/src/widgets/webwriter-task-explainer.ts +42 -42
  99. package/src/widgets/webwriter-task-hint.ts +22 -22
  100. package/src/widgets/webwriter-task-prompt.ts +23 -23
  101. package/src/widgets/webwriter-task.ts +546 -546
  102. package/src/widgets/webwriter-text.ts +155 -135
  103. package/tsconfig.json +6 -6
  104. package/.env +0 -1
@@ -1,432 +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
- 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
- }
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
432
  }