@webwriter/quiz 1.0.2 → 1.0.4

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 (45) hide show
  1. package/LICENSE +6 -6
  2. package/custom.d.ts +175 -175
  3. package/dist/widgets/webwriter-choice-item.js +3257 -59
  4. package/dist/widgets/webwriter-choice.js +4941 -79
  5. package/dist/widgets/webwriter-cloze-gap.js +4092 -179
  6. package/dist/widgets/webwriter-cloze.js +2415 -36
  7. package/dist/widgets/webwriter-mark.js +2471 -61
  8. package/dist/widgets/webwriter-order-item.js +2447 -68
  9. package/dist/widgets/webwriter-order.js +4990 -135
  10. package/dist/widgets/webwriter-pairing-item.js +1581 -48
  11. package/dist/widgets/webwriter-pairing.js +3686 -72
  12. package/dist/widgets/webwriter-quiz.js +6938 -76
  13. package/dist/widgets/webwriter-speech.js +6265 -76
  14. package/dist/widgets/webwriter-task-explainer.js +1564 -31
  15. package/dist/widgets/webwriter-task-hint.js +1523 -28
  16. package/dist/widgets/webwriter-task-prompt.js +1586 -29
  17. package/dist/widgets/webwriter-task.js +7132 -101
  18. package/dist/widgets/webwriter-text.js +4044 -54
  19. package/package.json +200 -200
  20. package/src/lib/combobox.ts +455 -455
  21. package/src/snippets/choice.html +7 -7
  22. package/src/snippets/cloze.html +3 -3
  23. package/src/snippets/mark.html +3 -3
  24. package/src/snippets/order.html +7 -7
  25. package/src/snippets/pairing.html +9 -9
  26. package/src/snippets/speech.html +3 -3
  27. package/src/snippets/text.html +3 -3
  28. package/src/snippets/wordsearch.html +3 -3
  29. package/src/widgets/webwriter-choice-item.ts +250 -250
  30. package/src/widgets/webwriter-choice.ts +309 -309
  31. package/src/widgets/webwriter-cloze-gap.ts +216 -216
  32. package/src/widgets/webwriter-cloze.ts +135 -135
  33. package/src/widgets/webwriter-mark.ts +434 -434
  34. package/src/widgets/webwriter-order-item.ts +444 -444
  35. package/src/widgets/webwriter-order.ts +272 -272
  36. package/src/widgets/webwriter-pairing-item.ts +155 -155
  37. package/src/widgets/webwriter-pairing.ts +187 -187
  38. package/src/widgets/webwriter-quiz.ts +280 -280
  39. package/src/widgets/webwriter-speech.ts +267 -267
  40. package/src/widgets/webwriter-task-explainer.ts +37 -37
  41. package/src/widgets/webwriter-task-hint.ts +16 -16
  42. package/src/widgets/webwriter-task-prompt.ts +17 -17
  43. package/src/widgets/webwriter-task.ts +543 -543
  44. package/src/widgets/webwriter-text.ts +130 -130
  45. package/tsconfig.json +6 -6
@@ -1,544 +1,544 @@
1
- import {html, css, PropertyValues} from "lit"
2
- import {styleMap} from "lit/directives/style-map.js"
3
- import {LitElementWw, option} from "@webwriter/lit"
4
- import {customElement, queryAssignedElements, property, query, queryAll} from "lit/decorators.js"
5
-
6
- import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js"
7
- import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js"
8
- import SlDetails from "@shoelace-style/shoelace/dist/components/details/details.component.js"
9
- import SlPopup from "@shoelace-style/shoelace/dist/components/popup/popup.component.js"
10
- import SlButtonGroup from "@shoelace-style/shoelace/dist/components/button-group/button-group.component.js"
11
-
12
- import SlTabGroup from "@shoelace-style/shoelace/dist/components/tab-group/tab-group.component.js"
13
- import SlTab from "@shoelace-style/shoelace/dist/components/tab/tab.component.js"
14
- import SlTabPanel from "@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.component.js"
15
-
16
- import IconPatchQuestion from "bootstrap-icons/icons/patch-question.svg"
17
- import IconPatchQuestionFill from "bootstrap-icons/icons/patch-question-fill.svg"
18
- import IconPatchCheck from "bootstrap-icons/icons/patch-check.svg"
19
- import IconPatchCheckFill from "bootstrap-icons/icons/patch-check-fill.svg"
20
-
21
- async function arrayBufferToDataUrl(buffer: ArrayBuffer | Uint8Array) {
22
- return new Promise(r => {
23
- const reader = new FileReader()
24
- reader.onload = () => r(reader.result as string)
25
- reader.readAsDataURL(new Blob([buffer]))
26
- }) as Promise<string>
27
- }
28
-
29
- async function dataUrlToArrayBuffer(url: string) {
30
- return await (await (await fetch(url)).blob()).arrayBuffer()
31
- }
32
-
33
- function getKeyMaterial(password: string) {
34
- let enc = new TextEncoder();
35
- return window.crypto.subtle.importKey(
36
- "raw",
37
- enc.encode(password),
38
- {name: "PBKDF2"},
39
- false,
40
- ["deriveBits", "deriveKey"]
41
- );
42
- }
43
-
44
- function getKey(keyMaterial: CryptoKey, salt: ArrayBufferView) {
45
- return window.crypto.subtle.deriveKey(
46
- {
47
- "name": "PBKDF2",
48
- salt: salt,
49
- "iterations": 100000,
50
- "hash": "SHA-256"
51
- },
52
- keyMaterial,
53
- { "name": "AES-GCM", "length": 256},
54
- true,
55
- [ "encrypt", "decrypt" ]
56
- )
57
- }
58
-
59
- import "@shoelace-style/shoelace/dist/themes/light.css"
60
- import { WebwriterTaskExplainer } from "./webwriter-task-explainer"
61
- function romanOrdinal(num: number, capitalize=false) {
62
- let roman = {
63
- m: 1000,
64
- cm: 900,
65
- d: 500,
66
- cd: 400,
67
- c: 100,
68
- xc: 90,
69
- l: 50,
70
- xl: 40,
71
- x: 10,
72
- ix: 9,
73
- v: 5,
74
- iv: 4,
75
- i: 1
76
- };
77
- let str = ''
78
-
79
- for (let i of Object.keys(roman)) {
80
- let q = Math.floor(num / roman[i])
81
- num -= q * roman[i]
82
- str += i.repeat(q)
83
- }
84
-
85
- return capitalize? str.toUpperCase(): str
86
- }
87
-
88
- function alphabeticalOrdinal(num: number, capitalize=false, alphabet="abcdefghijklmnopqrstuvwxyz") {
89
- const a = alphabet[0]
90
- const k = alphabet.length
91
- const str = a.repeat(Math.floor(num / k)) + alphabet[num % k]
92
- return capitalize? str.toUpperCase(): str
93
- }
94
-
95
-
96
-
97
- interface Answer {
98
- solution: any
99
- reportSolution(): void
100
- reset(): void
101
- }
102
-
103
- declare global {interface HTMLElementTagNameMap {
104
- "webwriter-task": WebwriterTask;
105
- }}
106
-
107
- @customElement("webwriter-task")
108
- export class WebwriterTask extends LitElementWw {
109
-
110
- static localization = {}
111
-
112
- static scopedElements = {
113
- "sl-icon-button": SlIconButton,
114
- "sl-details": SlDetails,
115
- "sl-popup": SlPopup,
116
- "sl-button": SlButton,
117
- "sl-button-group": SlButtonGroup,
118
- "sl-tab-group": SlTabGroup,
119
- "sl-tab": SlTab,
120
- "sl-tab-panel": SlTabPanel
121
- }
122
-
123
- msg = (str: string) => this.lang in WebwriterTask.localization? WebwriterTask.localization[this.lang][str] ?? str: str
124
-
125
- static styles = css`
126
- :host {
127
- display: flex !important;
128
- flex-direction: column;
129
- gap: 1rem;
130
- position: relative;
131
- z-index: 1;
132
- }
133
-
134
-
135
- :host(:not([hint])) details {
136
- display: none;
137
- }
138
-
139
- sl-tooltip::part(base__popup) {
140
- cursor: text;
141
- }
142
-
143
- #hint-popup {
144
- --arrow-color: var(--sl-color-neutral-700);
145
- z-index: 1000;
146
- }
147
-
148
- #hint-popup::part(popup) {
149
- z-index: 100;
150
- max-width: 200px;
151
- }
152
-
153
- :host(:not([contenteditable=true]):not([contenteditable=""]):not([hint])) #hint {
154
- display: none;
155
- }
156
-
157
- :host(:not([contenteditable=true]):not([contenteditable=""])) .author-only {
158
- display: none;
159
- }
160
-
161
- :host(:is([contenteditable=true], [contenteditable=""])) .user-only {
162
- display: none;
163
- }
164
-
165
- :host(:not([contenteditable=true]):not([contenteditable=""])) {
166
- ::slotted([slot=prompt]:empty) {
167
- display: none;
168
- }
169
- }
170
-
171
- :host(:not([submitted]):not([contenteditable=true]):not([contenteditable=""])) #explainer-group {
172
- display: none;
173
- }
174
-
175
- #task-buttons {
176
- position: absolute;
177
- right: 0;
178
- top: 0;
179
- background: rgba(255, 255, 255, 0.9);
180
- user-select: none;
181
- z-index: 100;
182
- }
183
-
184
- #hint-content {
185
- background: var(--sl-color-neutral-700);
186
- color: var(--sl-color-neutral-50);
187
- min-width: 2ch;
188
- font-size: 0.75rem;
189
- padding: 0.5rem;
190
- border-radius: 4px;
191
- user-select: auto;
192
- }
193
-
194
- ::slotted([slot=explainer]:not([active])) {
195
- display: none !important;
196
- }
197
-
198
- sl-tab-group {
199
- &[data-empty] {
200
- display: none;
201
- }
202
-
203
- &[data-single] sl-tab {
204
- display: none;
205
- }
206
-
207
- & sl-tab::part(base) {
208
- padding: 10px;
209
- }
210
-
211
- &::part(tabs) {
212
- height: 100px;
213
- margin-left: -1px;
214
- z-index: 10;
215
- }
216
-
217
- & ::slotted([name=explainer]) {
218
- height: 100%;
219
- }
220
- }
221
-
222
- header {
223
- display: flex;
224
- flex-direction: row;
225
- gap: 1ch;
226
-
227
- & span:empty {
228
- display: none;
229
- }
230
-
231
- & slot {
232
- display: block;
233
- flex-grow: 1;
234
- }
235
- }
236
-
237
- .user-actions {
238
- & #submit {
239
- flex-grow: 3;
240
- }
241
-
242
- & #reset {
243
- flex-grow: 1;
244
- }
245
- }
246
-
247
- `
248
-
249
- @queryAssignedElements({slot: "hint"})
250
- accessor hints: HTMLElement[]
251
-
252
- get hasHintElement() {
253
- return this.hints.length > 0
254
- }
255
-
256
- get hasHintContent() {
257
- return this.hints.some(hint => hint.innerText.trim() !== "")
258
- }
259
-
260
- @property({type: Boolean, attribute: true, reflect: true})
261
- accessor hint = false
262
-
263
- @property({type: Boolean, state: true, attribute: false, reflect: false})
264
- accessor isChanged = false
265
-
266
- get directSubmit() {
267
- return !this.closest("webwriter-quiz")
268
- }
269
-
270
- @property({type: Boolean, attribute: false, reflect: true})
271
- private set directSubmit(value: boolean) {
272
- return
273
- }
274
-
275
- @property({type: Boolean, attribute: true, reflect: true})
276
- accessor submitted = false
277
-
278
- toggleHint() {
279
- this.hintOpen = !this.hintOpen
280
- if(this.isContentEditable && this.hintOpen) {
281
- this.hint = true
282
- if(!this.hasHintElement) {
283
- const hintEl = this.ownerDocument.createElement("webwriter-task-hint")
284
- hintEl.slot = "hint"
285
- this.answer.insertAdjacentElement("beforebegin", hintEl)
286
- this.ownerDocument.getSelection().setBaseAndExtent(hintEl, 0, hintEl, 0)
287
- }
288
- }
289
- else if(this.isContentEditable && !this.hintOpen) {
290
- if(!this.hasHintContent) {
291
- this.hint = false
292
- this.hintSlotEl.assignedElements().forEach(el => el.remove())
293
- }
294
- }
295
- }
296
-
297
- get explainers(): WebwriterTaskExplainer[] {
298
- return Array.from(this.querySelectorAll("webwriter-task-explainer")) as unknown as WebwriterTaskExplainer[]
299
- }
300
-
301
- toggleExplainers = () => {
302
- if(this.explainers.length) {
303
- this.explainers.forEach(explainer => explainer.remove())
304
- this.activeExplainer = undefined
305
- }
306
- else {
307
- const solutionExplainer = this.ownerDocument.createElement("webwriter-task-explainer")
308
- solutionExplainer.slot = "explainer"
309
- solutionExplainer.id = "solution"
310
- solutionExplainer.active = true/*
311
- const elseExplainer = this.ownerDocument.createElement("webwriter-task-explainer")
312
- elseExplainer.slot = "explainer"
313
- elseExplainer.id = "else"*/
314
- this.append(solutionExplainer)
315
- this.ownerDocument.getSelection().setBaseAndExtent(solutionExplainer, 0, solutionExplainer, 0)
316
- this.requestUpdate()
317
- this.activeExplainer = "solution"
318
- }
319
- }
320
-
321
- @property({attribute: false, state: true})
322
- private accessor hintOpen = false
323
-
324
- @property({attribute: true, reflect: true})
325
- accessor counter: "number" | "roman" | "roman-capitalized" | "alphabetical" | "alphabetical-capitalized"
326
-
327
- get index() {
328
- return [...(this?.parentElement?.children ?? [])].indexOf(this)
329
- }
330
-
331
- get ordinalExpr() {
332
- if(this.index === undefined || this.index === -1) {
333
- return undefined
334
- }
335
- if(this.counter === "number") {
336
- return `${this.index + 1}.`
337
- }
338
- else if(this.counter === "roman") {
339
- return `${romanOrdinal(this.index + 1)}.`
340
- }
341
- else if(this.counter === "roman-capitalized") {
342
- return `${romanOrdinal(this.index + 1, true)}.`
343
- }
344
- else if(this.counter === "alphabetical") {
345
- return `${alphabeticalOrdinal(this.index)}.`
346
- }
347
- else if(this.counter === "alphabetical-capitalized") {
348
- return `${alphabeticalOrdinal(this.index, true)}.`
349
- }
350
- }
351
-
352
- observer: MutationObserver
353
-
354
- connectedCallback(): void {
355
- super.connectedCallback()
356
- this.observer = new MutationObserver(() => this.requestUpdate())
357
- this.parentElement && this.observer.observe(this.parentElement, {childList: true})
358
- this.addEventListener("keydown", (e) => this.handleHintKeydown(e))
359
- }
360
-
361
- protected firstUpdated(_changedProperties: PropertyValues): void {
362
- if(this.isContentEditable) {
363
- this.#decodeSolution()
364
- }
365
- }
366
-
367
-
368
- disconnectedCallback(): void {
369
- super.disconnectedCallback()
370
- this.observer.disconnect()
371
- }
372
-
373
- @property({type: String, attribute: true, reflect: true})
374
- accessor solution: string
375
-
376
- /** Property containing the password currently entered by the author or user */
377
- @property({type: String, attribute: false, reflect: false})
378
- // @option({type: String, label: {_: "Password"}, description: {_: "Password-protects quiz answers"}})
379
- accessor password: string = "B08bxd82SAOf"
380
-
381
- @property({type: String, attribute: true, reflect: true})
382
- accessor salt: string
383
-
384
- @property({type: String, attribute: true, reflect: true})
385
- accessor iv: string
386
-
387
- async #encodeSolution() {
388
- const value = this.answer.solution as any
389
- console.log(value)
390
- let keyMaterial = await getKeyMaterial(this.password)
391
- let salt = window.crypto.getRandomValues(new Uint8Array(16))
392
- let iv = window.crypto.getRandomValues(new Uint8Array(12))
393
- let key = await getKey(keyMaterial, salt)
394
- let encoder = new TextEncoder();
395
- let encodedMessage = encoder.encode(JSON.stringify(value))
396
- const solution = await window.crypto.subtle.encrypt(
397
- {name: "AES-GCM", iv},
398
- key,
399
- encodedMessage
400
- )
401
-
402
- this.solution = await arrayBufferToDataUrl(solution)
403
- this.iv = await arrayBufferToDataUrl(iv)
404
- this.salt = await arrayBufferToDataUrl(salt)
405
- }
406
-
407
- async #decodeSolution() {
408
- if(!this.solution) {
409
- return
410
- }
411
- const encodedSolution = await dataUrlToArrayBuffer(this.solution)
412
- const iv = await dataUrlToArrayBuffer(this.iv)
413
- const salt = await dataUrlToArrayBuffer(this.salt)
414
- let keyMaterial = await getKeyMaterial(this.password)
415
- let key = await getKey(keyMaterial, new Uint8Array(salt))
416
- try {
417
- const solutionBuffer = await window.crypto.subtle.decrypt({name: "AES-GCM", iv}, key, encodedSolution)
418
- let decoder = new TextDecoder()
419
- const solution = JSON.parse(decoder.decode(solutionBuffer))
420
- this.answer.solution = solution
421
- }
422
- catch(err) {
423
- console.error(err)
424
- throw new Error("Invalid password")
425
- }
426
- }
427
-
428
- checkSolution() {
429
- const solution = this.#decodeSolution()
430
- return Object.entries(solution).every(([k, v]) => {
431
- return JSON.stringify(this.answer[k]) === JSON.stringify(v)
432
- })
433
- }
434
-
435
- reportSolution() {
436
- // @ts-ignore
437
- this.answer.reportSolution(this.#decodeSolution())
438
- }
439
-
440
- @query("slot:not([name])")
441
- accessor slotEl: HTMLSlotElement
442
-
443
- @query("slot[name=hint]")
444
- accessor hintSlotEl: HTMLSlotElement
445
-
446
- get answer() {
447
- return this.slotEl?.assignedElements()[0] as HTMLElement
448
- }
449
-
450
-
451
-
452
- handleHintSlotChange = (e: Event) => {
453
- if(!this.hasHintElement) {
454
- this.hintOpen = false
455
- this.hint = false
456
- }
457
- }
458
-
459
- handleHintKeydown = (e: KeyboardEvent) => {
460
- console.log(document.getSelection().anchorOffset === 0, this.hints.includes(document.getSelection().anchorNode.parentElement))
461
- if(e.key === "Backspace" && document.getSelection().anchorOffset === 0 && this.hints.includes(document.getSelection().anchorNode.parentElement)) {
462
- this.hintOpen = false
463
- this.hint = false
464
- }
465
- }
466
-
467
- handleSubmit = async () => {
468
- await this.#decodeSolution()
469
- this.answer.reportSolution()
470
- this.dispatchEvent(new SubmitEvent("submit", {bubbles: true, composed: true}))
471
- this.activeExplainer = this.explainers[0].id
472
- this.submitted = true
473
- }
474
-
475
- handleReset = () => {
476
- this.answer.reset && this.answer.reset()
477
- this.isChanged = false
478
- this.submitted = false
479
- }
480
-
481
- handleAnswerChange = async (e: CustomEvent) => {
482
- this.isChanged = true
483
- if(this.isContentEditable) {
484
- this.#encodeSolution()
485
- }
486
- }
487
-
488
- handleSlotChange = (e: Event) => {
489
- this.requestUpdate()
490
- if(this.isContentEditable) {
491
- const solution = this.#decodeSolution() ?? {}
492
- Object.entries(solution).forEach(([k, v]) => {
493
- this.answer[k] = v
494
- })
495
- }
496
- }
497
-
498
- @property()
499
- accessor activeExplainer: string
500
-
501
- selectExplainer(id: string) {
502
- const explainer = this.explainers.find(node => node.id === id)
503
- explainer.active = true
504
- this.explainers.filter(node => node.id !== id).forEach(node => node.active = false)
505
- this.activeExplainer = id
506
- setTimeout(() => this.ownerDocument.getSelection().setBaseAndExtent(explainer, 0, explainer, 0))
507
- }
508
-
509
- get explainerLabels() {
510
- return {
511
- "solution": "Explainer",
512
- "else": "Else"
513
- }
514
- }
515
-
516
- render() {
517
- return html`
518
- <header>
519
- <span>${this.ordinalExpr}</span>
520
- <slot name="prompt"></slot>
521
- <div id="task-buttons">
522
- <sl-icon-button class="author-only" id="feedback" src=${!this.explainers.length? IconPatchCheck: IconPatchCheckFill} @click=${() => this.toggleExplainers()}></sl-icon-button>
523
- <sl-popup id="hint-popup" ?active=${this.hintOpen} placement="left" arrow auto-size shift @selectstart=${e => e.stopImmediatePropagation()}>
524
- <sl-icon-button class="author-only" slot="anchor" id="hint" src=${!this.hasHintContent && !this.hintOpen? IconPatchQuestion: IconPatchQuestionFill} @click=${() => this.toggleHint()}></sl-icon-button>
525
- <div id="hint-content">
526
- <slot name="hint" @slotchange=${this.handleHintSlotChange}></slot>
527
- </div>
528
- </sl-popup>
529
- </div>
530
- </header>
531
- <slot @ww-answer-change=${this.handleAnswerChange} @slotchange=${this.handleSlotChange} ?inert=${this.submitted}></slot>
532
- <sl-tab-group id="explainer-group" placement="end" ?data-empty=${!this.explainers.length} ?data-single=${this.explainers.length === 1}>
533
- ${this.explainers.map((explainer, i) => html`<sl-tab ?active=${this.activeExplainer === explainer.id} slot="nav" @click=${() => this.selectExplainer(explainer.id)}>${this.explainerLabels[explainer.id] ?? explainer.id}</sl-tab>`)}
534
- <slot name="explainer" style=${styleMap({"--ww-placeholder": `"${this.msg("Explanation")}"`})}></slot>
535
- </sl-tab-group>
536
- ${!this.directSubmit || !this.answer?.reportSolution? null: html`
537
- <sl-button-group class="user-only user-actions">
538
- <sl-button id="submit" @click=${this.handleSubmit}>Check your answers</sl-button>
539
- <sl-button ?disabled=${!this.isChanged && !this.submitted} id="reset" class="user-only" @click=${this.handleReset}>Try again</sl-button>
540
- </sl-button-group>
541
- `}
542
- `
543
- }
1
+ import {html, css, PropertyValues} from "lit"
2
+ import {styleMap} from "lit/directives/style-map.js"
3
+ import {LitElementWw, option} from "@webwriter/lit"
4
+ import {customElement, queryAssignedElements, property, query, queryAll} from "lit/decorators.js"
5
+
6
+ import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js"
7
+ import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js"
8
+ import SlDetails from "@shoelace-style/shoelace/dist/components/details/details.component.js"
9
+ import SlPopup from "@shoelace-style/shoelace/dist/components/popup/popup.component.js"
10
+ import SlButtonGroup from "@shoelace-style/shoelace/dist/components/button-group/button-group.component.js"
11
+
12
+ import SlTabGroup from "@shoelace-style/shoelace/dist/components/tab-group/tab-group.component.js"
13
+ import SlTab from "@shoelace-style/shoelace/dist/components/tab/tab.component.js"
14
+ import SlTabPanel from "@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.component.js"
15
+
16
+ import IconPatchQuestion from "bootstrap-icons/icons/patch-question.svg"
17
+ import IconPatchQuestionFill from "bootstrap-icons/icons/patch-question-fill.svg"
18
+ import IconPatchCheck from "bootstrap-icons/icons/patch-check.svg"
19
+ import IconPatchCheckFill from "bootstrap-icons/icons/patch-check-fill.svg"
20
+
21
+ async function arrayBufferToDataUrl(buffer: ArrayBuffer | Uint8Array) {
22
+ return new Promise(r => {
23
+ const reader = new FileReader()
24
+ reader.onload = () => r(reader.result as string)
25
+ reader.readAsDataURL(new Blob([buffer]))
26
+ }) as Promise<string>
27
+ }
28
+
29
+ async function dataUrlToArrayBuffer(url: string) {
30
+ return await (await (await fetch(url)).blob()).arrayBuffer()
31
+ }
32
+
33
+ function getKeyMaterial(password: string) {
34
+ let enc = new TextEncoder();
35
+ return window.crypto.subtle.importKey(
36
+ "raw",
37
+ enc.encode(password),
38
+ {name: "PBKDF2"},
39
+ false,
40
+ ["deriveBits", "deriveKey"]
41
+ );
42
+ }
43
+
44
+ function getKey(keyMaterial: CryptoKey, salt: ArrayBufferView) {
45
+ return window.crypto.subtle.deriveKey(
46
+ {
47
+ "name": "PBKDF2",
48
+ salt: salt,
49
+ "iterations": 100000,
50
+ "hash": "SHA-256"
51
+ },
52
+ keyMaterial,
53
+ { "name": "AES-GCM", "length": 256},
54
+ true,
55
+ [ "encrypt", "decrypt" ]
56
+ )
57
+ }
58
+
59
+ import "@shoelace-style/shoelace/dist/themes/light.css"
60
+ import { WebwriterTaskExplainer } from "./webwriter-task-explainer"
61
+ function romanOrdinal(num: number, capitalize=false) {
62
+ let roman = {
63
+ m: 1000,
64
+ cm: 900,
65
+ d: 500,
66
+ cd: 400,
67
+ c: 100,
68
+ xc: 90,
69
+ l: 50,
70
+ xl: 40,
71
+ x: 10,
72
+ ix: 9,
73
+ v: 5,
74
+ iv: 4,
75
+ i: 1
76
+ };
77
+ let str = ''
78
+
79
+ for (let i of Object.keys(roman)) {
80
+ let q = Math.floor(num / roman[i])
81
+ num -= q * roman[i]
82
+ str += i.repeat(q)
83
+ }
84
+
85
+ return capitalize? str.toUpperCase(): str
86
+ }
87
+
88
+ function alphabeticalOrdinal(num: number, capitalize=false, alphabet="abcdefghijklmnopqrstuvwxyz") {
89
+ const a = alphabet[0]
90
+ const k = alphabet.length
91
+ const str = a.repeat(Math.floor(num / k)) + alphabet[num % k]
92
+ return capitalize? str.toUpperCase(): str
93
+ }
94
+
95
+
96
+
97
+ interface Answer {
98
+ solution: any
99
+ reportSolution(): void
100
+ reset(): void
101
+ }
102
+
103
+ declare global {interface HTMLElementTagNameMap {
104
+ "webwriter-task": WebwriterTask;
105
+ }}
106
+
107
+ @customElement("webwriter-task")
108
+ export class WebwriterTask extends LitElementWw {
109
+
110
+ static localization = {}
111
+
112
+ static scopedElements = {
113
+ "sl-icon-button": SlIconButton,
114
+ "sl-details": SlDetails,
115
+ "sl-popup": SlPopup,
116
+ "sl-button": SlButton,
117
+ "sl-button-group": SlButtonGroup,
118
+ "sl-tab-group": SlTabGroup,
119
+ "sl-tab": SlTab,
120
+ "sl-tab-panel": SlTabPanel
121
+ }
122
+
123
+ msg = (str: string) => this.lang in WebwriterTask.localization? WebwriterTask.localization[this.lang][str] ?? str: str
124
+
125
+ static styles = css`
126
+ :host {
127
+ display: flex !important;
128
+ flex-direction: column;
129
+ gap: 1rem;
130
+ position: relative;
131
+ z-index: 1;
132
+ }
133
+
134
+
135
+ :host(:not([hint])) details {
136
+ display: none;
137
+ }
138
+
139
+ sl-tooltip::part(base__popup) {
140
+ cursor: text;
141
+ }
142
+
143
+ #hint-popup {
144
+ --arrow-color: var(--sl-color-neutral-700);
145
+ z-index: 1000;
146
+ }
147
+
148
+ #hint-popup::part(popup) {
149
+ z-index: 100;
150
+ max-width: 200px;
151
+ }
152
+
153
+ :host(:not([contenteditable=true]):not([contenteditable=""]):not([hint])) #hint {
154
+ display: none;
155
+ }
156
+
157
+ :host(:not([contenteditable=true]):not([contenteditable=""])) .author-only {
158
+ display: none;
159
+ }
160
+
161
+ :host(:is([contenteditable=true], [contenteditable=""])) .user-only {
162
+ display: none;
163
+ }
164
+
165
+ :host(:not([contenteditable=true]):not([contenteditable=""])) {
166
+ ::slotted([slot=prompt]:empty) {
167
+ display: none;
168
+ }
169
+ }
170
+
171
+ :host(:not([submitted]):not([contenteditable=true]):not([contenteditable=""])) #explainer-group {
172
+ display: none;
173
+ }
174
+
175
+ #task-buttons {
176
+ position: absolute;
177
+ right: 0;
178
+ top: 0;
179
+ background: rgba(255, 255, 255, 0.9);
180
+ user-select: none;
181
+ z-index: 100;
182
+ }
183
+
184
+ #hint-content {
185
+ background: var(--sl-color-neutral-700);
186
+ color: var(--sl-color-neutral-50);
187
+ min-width: 2ch;
188
+ font-size: 0.75rem;
189
+ padding: 0.5rem;
190
+ border-radius: 4px;
191
+ user-select: auto;
192
+ }
193
+
194
+ ::slotted([slot=explainer]:not([active])) {
195
+ display: none !important;
196
+ }
197
+
198
+ sl-tab-group {
199
+ &[data-empty] {
200
+ display: none;
201
+ }
202
+
203
+ &[data-single] sl-tab {
204
+ display: none;
205
+ }
206
+
207
+ & sl-tab::part(base) {
208
+ padding: 10px;
209
+ }
210
+
211
+ &::part(tabs) {
212
+ height: 100px;
213
+ margin-left: -1px;
214
+ z-index: 10;
215
+ }
216
+
217
+ & ::slotted([name=explainer]) {
218
+ height: 100%;
219
+ }
220
+ }
221
+
222
+ header {
223
+ display: flex;
224
+ flex-direction: row;
225
+ gap: 1ch;
226
+
227
+ & span:empty {
228
+ display: none;
229
+ }
230
+
231
+ & slot {
232
+ display: block;
233
+ flex-grow: 1;
234
+ }
235
+ }
236
+
237
+ .user-actions {
238
+ & #submit {
239
+ flex-grow: 3;
240
+ }
241
+
242
+ & #reset {
243
+ flex-grow: 1;
244
+ }
245
+ }
246
+
247
+ `
248
+
249
+ @queryAssignedElements({slot: "hint"})
250
+ accessor hints: HTMLElement[]
251
+
252
+ get hasHintElement() {
253
+ return this.hints.length > 0
254
+ }
255
+
256
+ get hasHintContent() {
257
+ return this.hints.some(hint => hint.innerText.trim() !== "")
258
+ }
259
+
260
+ @property({type: Boolean, attribute: true, reflect: true})
261
+ accessor hint = false
262
+
263
+ @property({type: Boolean, state: true, attribute: false, reflect: false})
264
+ accessor isChanged = false
265
+
266
+ get directSubmit() {
267
+ return !this.closest("webwriter-quiz")
268
+ }
269
+
270
+ @property({type: Boolean, attribute: false, reflect: true})
271
+ private set directSubmit(value: boolean) {
272
+ return
273
+ }
274
+
275
+ @property({type: Boolean, attribute: true, reflect: true})
276
+ accessor submitted = false
277
+
278
+ toggleHint() {
279
+ this.hintOpen = !this.hintOpen
280
+ if(this.isContentEditable && this.hintOpen) {
281
+ this.hint = true
282
+ if(!this.hasHintElement) {
283
+ const hintEl = this.ownerDocument.createElement("webwriter-task-hint")
284
+ hintEl.slot = "hint"
285
+ this.answer.insertAdjacentElement("beforebegin", hintEl)
286
+ this.ownerDocument.getSelection().setBaseAndExtent(hintEl, 0, hintEl, 0)
287
+ }
288
+ }
289
+ else if(this.isContentEditable && !this.hintOpen) {
290
+ if(!this.hasHintContent) {
291
+ this.hint = false
292
+ this.hintSlotEl.assignedElements().forEach(el => el.remove())
293
+ }
294
+ }
295
+ }
296
+
297
+ get explainers(): WebwriterTaskExplainer[] {
298
+ return Array.from(this.querySelectorAll("webwriter-task-explainer")) as unknown as WebwriterTaskExplainer[]
299
+ }
300
+
301
+ toggleExplainers = () => {
302
+ if(this.explainers.length) {
303
+ this.explainers.forEach(explainer => explainer.remove())
304
+ this.activeExplainer = undefined
305
+ }
306
+ else {
307
+ const solutionExplainer = this.ownerDocument.createElement("webwriter-task-explainer")
308
+ solutionExplainer.slot = "explainer"
309
+ solutionExplainer.id = "solution"
310
+ solutionExplainer.active = true/*
311
+ const elseExplainer = this.ownerDocument.createElement("webwriter-task-explainer")
312
+ elseExplainer.slot = "explainer"
313
+ elseExplainer.id = "else"*/
314
+ this.append(solutionExplainer)
315
+ this.ownerDocument.getSelection().setBaseAndExtent(solutionExplainer, 0, solutionExplainer, 0)
316
+ this.requestUpdate()
317
+ this.activeExplainer = "solution"
318
+ }
319
+ }
320
+
321
+ @property({attribute: false, state: true})
322
+ private accessor hintOpen = false
323
+
324
+ @property({attribute: true, reflect: true})
325
+ accessor counter: "number" | "roman" | "roman-capitalized" | "alphabetical" | "alphabetical-capitalized"
326
+
327
+ get index() {
328
+ return [...(this?.parentElement?.children ?? [])].indexOf(this)
329
+ }
330
+
331
+ get ordinalExpr() {
332
+ if(this.index === undefined || this.index === -1) {
333
+ return undefined
334
+ }
335
+ if(this.counter === "number") {
336
+ return `${this.index + 1}.`
337
+ }
338
+ else if(this.counter === "roman") {
339
+ return `${romanOrdinal(this.index + 1)}.`
340
+ }
341
+ else if(this.counter === "roman-capitalized") {
342
+ return `${romanOrdinal(this.index + 1, true)}.`
343
+ }
344
+ else if(this.counter === "alphabetical") {
345
+ return `${alphabeticalOrdinal(this.index)}.`
346
+ }
347
+ else if(this.counter === "alphabetical-capitalized") {
348
+ return `${alphabeticalOrdinal(this.index, true)}.`
349
+ }
350
+ }
351
+
352
+ observer: MutationObserver
353
+
354
+ connectedCallback(): void {
355
+ super.connectedCallback()
356
+ this.observer = new MutationObserver(() => this.requestUpdate())
357
+ this.parentElement && this.observer.observe(this.parentElement, {childList: true})
358
+ this.addEventListener("keydown", (e) => this.handleHintKeydown(e))
359
+ }
360
+
361
+ protected firstUpdated(_changedProperties: PropertyValues): void {
362
+ if(this.isContentEditable) {
363
+ this.#decodeSolution()
364
+ }
365
+ }
366
+
367
+
368
+ disconnectedCallback(): void {
369
+ super.disconnectedCallback()
370
+ this.observer.disconnect()
371
+ }
372
+
373
+ @property({type: String, attribute: true, reflect: true})
374
+ accessor solution: string
375
+
376
+ /** Property containing the password currently entered by the author or user */
377
+ @property({type: String, attribute: false, reflect: false})
378
+ // @option({type: String, label: {_: "Password"}, description: {_: "Password-protects quiz answers"}})
379
+ accessor password: string = "B08bxd82SAOf"
380
+
381
+ @property({type: String, attribute: true, reflect: true})
382
+ accessor salt: string
383
+
384
+ @property({type: String, attribute: true, reflect: true})
385
+ accessor iv: string
386
+
387
+ async #encodeSolution() {
388
+ const value = this.answer.solution as any
389
+ console.log(value)
390
+ let keyMaterial = await getKeyMaterial(this.password)
391
+ let salt = window.crypto.getRandomValues(new Uint8Array(16))
392
+ let iv = window.crypto.getRandomValues(new Uint8Array(12))
393
+ let key = await getKey(keyMaterial, salt)
394
+ let encoder = new TextEncoder();
395
+ let encodedMessage = encoder.encode(JSON.stringify(value))
396
+ const solution = await window.crypto.subtle.encrypt(
397
+ {name: "AES-GCM", iv},
398
+ key,
399
+ encodedMessage
400
+ )
401
+
402
+ this.solution = await arrayBufferToDataUrl(solution)
403
+ this.iv = await arrayBufferToDataUrl(iv)
404
+ this.salt = await arrayBufferToDataUrl(salt)
405
+ }
406
+
407
+ async #decodeSolution() {
408
+ if(!this.solution) {
409
+ return
410
+ }
411
+ const encodedSolution = await dataUrlToArrayBuffer(this.solution)
412
+ const iv = await dataUrlToArrayBuffer(this.iv)
413
+ const salt = await dataUrlToArrayBuffer(this.salt)
414
+ let keyMaterial = await getKeyMaterial(this.password)
415
+ let key = await getKey(keyMaterial, new Uint8Array(salt))
416
+ try {
417
+ const solutionBuffer = await window.crypto.subtle.decrypt({name: "AES-GCM", iv}, key, encodedSolution)
418
+ let decoder = new TextDecoder()
419
+ const solution = JSON.parse(decoder.decode(solutionBuffer))
420
+ this.answer.solution = solution
421
+ }
422
+ catch(err) {
423
+ console.error(err)
424
+ throw new Error("Invalid password")
425
+ }
426
+ }
427
+
428
+ checkSolution() {
429
+ const solution = this.#decodeSolution()
430
+ return Object.entries(solution).every(([k, v]) => {
431
+ return JSON.stringify(this.answer[k]) === JSON.stringify(v)
432
+ })
433
+ }
434
+
435
+ reportSolution() {
436
+ // @ts-ignore
437
+ this.answer.reportSolution(this.#decodeSolution())
438
+ }
439
+
440
+ @query("slot:not([name])")
441
+ accessor slotEl: HTMLSlotElement
442
+
443
+ @query("slot[name=hint]")
444
+ accessor hintSlotEl: HTMLSlotElement
445
+
446
+ get answer() {
447
+ return this.slotEl?.assignedElements()[0] as HTMLElement
448
+ }
449
+
450
+
451
+
452
+ handleHintSlotChange = (e: Event) => {
453
+ if(!this.hasHintElement) {
454
+ this.hintOpen = false
455
+ this.hint = false
456
+ }
457
+ }
458
+
459
+ handleHintKeydown = (e: KeyboardEvent) => {
460
+ console.log(document.getSelection().anchorOffset === 0, this.hints.includes(document.getSelection().anchorNode.parentElement))
461
+ if(e.key === "Backspace" && document.getSelection().anchorOffset === 0 && this.hints.includes(document.getSelection().anchorNode.parentElement)) {
462
+ this.hintOpen = false
463
+ this.hint = false
464
+ }
465
+ }
466
+
467
+ handleSubmit = async () => {
468
+ await this.#decodeSolution()
469
+ this.answer.reportSolution()
470
+ this.dispatchEvent(new SubmitEvent("submit", {bubbles: true, composed: true}))
471
+ this.activeExplainer = this.explainers[0].id
472
+ this.submitted = true
473
+ }
474
+
475
+ handleReset = () => {
476
+ this.answer.reset && this.answer.reset()
477
+ this.isChanged = false
478
+ this.submitted = false
479
+ }
480
+
481
+ handleAnswerChange = async (e: CustomEvent) => {
482
+ this.isChanged = true
483
+ if(this.isContentEditable) {
484
+ this.#encodeSolution()
485
+ }
486
+ }
487
+
488
+ handleSlotChange = (e: Event) => {
489
+ this.requestUpdate()
490
+ if(this.isContentEditable) {
491
+ const solution = this.#decodeSolution() ?? {}
492
+ Object.entries(solution).forEach(([k, v]) => {
493
+ this.answer[k] = v
494
+ })
495
+ }
496
+ }
497
+
498
+ @property()
499
+ accessor activeExplainer: string
500
+
501
+ selectExplainer(id: string) {
502
+ const explainer = this.explainers.find(node => node.id === id)
503
+ explainer.active = true
504
+ this.explainers.filter(node => node.id !== id).forEach(node => node.active = false)
505
+ this.activeExplainer = id
506
+ setTimeout(() => this.ownerDocument.getSelection().setBaseAndExtent(explainer, 0, explainer, 0))
507
+ }
508
+
509
+ get explainerLabels() {
510
+ return {
511
+ "solution": "Explainer",
512
+ "else": "Else"
513
+ }
514
+ }
515
+
516
+ render() {
517
+ return html`
518
+ <header>
519
+ <span>${this.ordinalExpr}</span>
520
+ <slot name="prompt"></slot>
521
+ <div id="task-buttons">
522
+ <sl-icon-button class="author-only" id="feedback" src=${!this.explainers.length? IconPatchCheck: IconPatchCheckFill} @click=${() => this.toggleExplainers()}></sl-icon-button>
523
+ <sl-popup id="hint-popup" ?active=${this.hintOpen} placement="left" arrow auto-size shift @selectstart=${e => e.stopImmediatePropagation()}>
524
+ <sl-icon-button class="author-only" slot="anchor" id="hint" src=${!this.hasHintContent && !this.hintOpen? IconPatchQuestion: IconPatchQuestionFill} @click=${() => this.toggleHint()}></sl-icon-button>
525
+ <div id="hint-content">
526
+ <slot name="hint" @slotchange=${this.handleHintSlotChange}></slot>
527
+ </div>
528
+ </sl-popup>
529
+ </div>
530
+ </header>
531
+ <slot @ww-answer-change=${this.handleAnswerChange} @slotchange=${this.handleSlotChange} ?inert=${this.submitted}></slot>
532
+ <sl-tab-group id="explainer-group" placement="end" ?data-empty=${!this.explainers.length} ?data-single=${this.explainers.length === 1}>
533
+ ${this.explainers.map((explainer, i) => html`<sl-tab ?active=${this.activeExplainer === explainer.id} slot="nav" @click=${() => this.selectExplainer(explainer.id)}>${this.explainerLabels[explainer.id] ?? explainer.id}</sl-tab>`)}
534
+ <slot name="explainer" style=${styleMap({"--ww-placeholder": `"${this.msg("Explanation")}"`})}></slot>
535
+ </sl-tab-group>
536
+ ${!this.directSubmit || !this.answer?.reportSolution? null: html`
537
+ <sl-button-group class="user-only user-actions">
538
+ <sl-button id="submit" @click=${this.handleSubmit}>Check your answers</sl-button>
539
+ <sl-button ?disabled=${!this.isChanged && !this.submitted} id="reset" class="user-only" @click=${this.handleReset}>Try again</sl-button>
540
+ </sl-button-group>
541
+ `}
542
+ `
543
+ }
544
544
  }