@webwriter/quiz 1.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 (62) hide show
  1. package/LICENSE +7 -0
  2. package/custom.d.ts +176 -0
  3. package/dist/widgets/webwriter-choice-item.css +444 -0
  4. package/dist/widgets/webwriter-choice-item.js +288 -0
  5. package/dist/widgets/webwriter-choice.css +444 -0
  6. package/dist/widgets/webwriter-choice.js +327 -0
  7. package/dist/widgets/webwriter-cloze-gap.css +444 -0
  8. package/dist/widgets/webwriter-cloze-gap.js +722 -0
  9. package/dist/widgets/webwriter-cloze.css +444 -0
  10. package/dist/widgets/webwriter-cloze.js +177 -0
  11. package/dist/widgets/webwriter-mark.css +444 -0
  12. package/dist/widgets/webwriter-mark.js +410 -0
  13. package/dist/widgets/webwriter-order-item.css +444 -0
  14. package/dist/widgets/webwriter-order-item.js +464 -0
  15. package/dist/widgets/webwriter-order.css +444 -0
  16. package/dist/widgets/webwriter-order.js +560 -0
  17. package/dist/widgets/webwriter-pairing-item.css +444 -0
  18. package/dist/widgets/webwriter-pairing-item.js +203 -0
  19. package/dist/widgets/webwriter-pairing.css +444 -0
  20. package/dist/widgets/webwriter-pairing.js +224 -0
  21. package/dist/widgets/webwriter-quiz.css +444 -0
  22. package/dist/widgets/webwriter-quiz.js +323 -0
  23. package/dist/widgets/webwriter-speech.css +444 -0
  24. package/dist/widgets/webwriter-speech.js +320 -0
  25. package/dist/widgets/webwriter-task-explainer.css +444 -0
  26. package/dist/widgets/webwriter-task-explainer.js +90 -0
  27. package/dist/widgets/webwriter-task-hint.css +444 -0
  28. package/dist/widgets/webwriter-task-hint.js +67 -0
  29. package/dist/widgets/webwriter-task-prompt.css +444 -0
  30. package/dist/widgets/webwriter-task-prompt.js +68 -0
  31. package/dist/widgets/webwriter-task.css +444 -0
  32. package/dist/widgets/webwriter-task.js +546 -0
  33. package/dist/widgets/webwriter-text.css +444 -0
  34. package/dist/widgets/webwriter-text.js +174 -0
  35. package/package.json +199 -0
  36. package/src/lib/combobox.ts +456 -0
  37. package/src/lib/highlighter-fill.svg +6 -0
  38. package/src/snippets/choice.html +8 -0
  39. package/src/snippets/cloze.html +4 -0
  40. package/src/snippets/mark.html +4 -0
  41. package/src/snippets/order.html +8 -0
  42. package/src/snippets/pairing.html +10 -0
  43. package/src/snippets/speech.html +4 -0
  44. package/src/snippets/text.html +4 -0
  45. package/src/snippets/wordsearch.html +4 -0
  46. package/src/widgets/webwriter-choice-item.ts +251 -0
  47. package/src/widgets/webwriter-choice.ts +310 -0
  48. package/src/widgets/webwriter-cloze-gap.ts +217 -0
  49. package/src/widgets/webwriter-cloze.ts +136 -0
  50. package/src/widgets/webwriter-mark.ts +435 -0
  51. package/src/widgets/webwriter-order-item.ts +445 -0
  52. package/src/widgets/webwriter-order.ts +273 -0
  53. package/src/widgets/webwriter-pairing-item.ts +156 -0
  54. package/src/widgets/webwriter-pairing.ts +188 -0
  55. package/src/widgets/webwriter-quiz.ts +281 -0
  56. package/src/widgets/webwriter-speech.ts +268 -0
  57. package/src/widgets/webwriter-task-explainer.ts +38 -0
  58. package/src/widgets/webwriter-task-hint.ts +17 -0
  59. package/src/widgets/webwriter-task-prompt.ts +18 -0
  60. package/src/widgets/webwriter-task.ts +544 -0
  61. package/src/widgets/webwriter-text.ts +131 -0
  62. package/tsconfig.json +7 -0
@@ -0,0 +1,435 @@
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
+ }
435
+ }