@webwriter/quiz 1.1.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +778 -0
  3. package/assets/icons/grip-horizontal.svg +8 -0
  4. package/assets/icons/grip-vertical.svg +8 -0
  5. package/assets/icons/truefalse.svg +6 -0
  6. package/custom-elements.json +2779 -0
  7. package/custom.d.ts +5 -1
  8. package/dist/api/index.d.ts +27 -0
  9. package/dist/api/index.js +40 -0
  10. package/dist/migrate.js +1434 -0
  11. package/dist/widgets/webwriter-choice-item.js +4095 -3818
  12. package/dist/widgets/webwriter-choice.js +2179 -5598
  13. package/dist/widgets/webwriter-gap-item.js +3987 -0
  14. package/dist/widgets/webwriter-gap.js +2908 -0
  15. package/dist/widgets/webwriter-mark.js +4347 -3368
  16. package/dist/widgets/webwriter-order-item.js +4038 -3197
  17. package/dist/widgets/webwriter-order.js +2885 -4200
  18. package/dist/widgets/webwriter-pairing-item.js +1070 -2330
  19. package/dist/widgets/webwriter-pairing.js +2968 -4231
  20. package/dist/widgets/webwriter-quiz-hint.js +2469 -0
  21. package/dist/widgets/webwriter-quiz.js +3337 -4364
  22. package/dist/widgets/webwriter-speech.js +6100 -5120
  23. package/dist/widgets/webwriter-task-prompt.js +4787 -2068
  24. package/dist/widgets/webwriter-task.js +2264 -8157
  25. package/dist/widgets/webwriter-text.js +4075 -4132
  26. package/dist/widgets/webwriter-true-false.js +4674 -0
  27. package/editing-config.json +106 -0
  28. package/lit-localize.json +20 -14
  29. package/localization/de.xlf +395 -65
  30. package/localization/es.xlf +396 -61
  31. package/localization/fr.xlf +396 -61
  32. package/localization/generated/de.ts +97 -15
  33. package/localization/generated/es.ts +97 -15
  34. package/localization/generated/fr.ts +98 -16
  35. package/localization/generated/index.d.ts +5 -0
  36. package/localization/generated/it.ts +96 -14
  37. package/localization/generated/locale-codes.js +0 -48
  38. package/localization/generated/nl.ts +99 -17
  39. package/localization/it.xlf +396 -61
  40. package/localization/nl.xlf +396 -61
  41. package/package.json +120 -254
  42. package/scripts/apply-translations.mjs +97 -0
  43. package/src/api/index.ts +72 -0
  44. package/src/lib/artboard.ts +686 -0
  45. package/src/lib/draggable-grid.ts +432 -0
  46. package/src/lib/encrypted-property.ts +202 -0
  47. package/src/lib/hint-popup.ts +152 -0
  48. package/src/lib/utils.ts +97 -0
  49. package/src/lib/webwriter-quirks.ts +121 -0
  50. package/src/migrate/index.ts +622 -0
  51. package/src/migrate/legacy-solution.ts +362 -0
  52. package/src/snippets/order.html +8 -7
  53. package/src/widgets/choice/webwriter-choice-item.ts +316 -0
  54. package/src/widgets/choice/webwriter-choice.ts +328 -0
  55. package/src/widgets/choice/webwriter-true-false.ts +113 -0
  56. package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
  57. package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
  58. package/src/widgets/dnd/webwriter-dnd.ts +36 -0
  59. package/src/widgets/gap/webwriter-gap-item.ts +250 -0
  60. package/src/widgets/gap/webwriter-gap.ts +447 -0
  61. package/src/widgets/order/webwriter-order-item.ts +243 -0
  62. package/src/widgets/order/webwriter-order.ts +282 -0
  63. package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
  64. package/src/widgets/pairing/webwriter-pairing.ts +934 -0
  65. package/src/widgets/speech/audio-player.ts +325 -0
  66. package/src/widgets/speech/audio-recorder.ts +285 -0
  67. package/src/widgets/speech/waveform.ts +113 -0
  68. package/src/widgets/speech/webwriter-speech.ts +87 -0
  69. package/src/widgets/webwriter-mark.ts +366 -424
  70. package/src/widgets/webwriter-quiz-hint.ts +36 -0
  71. package/src/widgets/webwriter-quiz.ts +318 -280
  72. package/src/widgets/webwriter-task-prompt.ts +43 -18
  73. package/src/widgets/webwriter-task.ts +212 -551
  74. package/src/widgets/webwriter-text.ts +286 -172
  75. package/tsconfig.api.json +12 -0
  76. package/tsconfig.json +7 -6
  77. package/dist/widgets/webwriter-choice.css +0 -444
  78. package/dist/widgets/webwriter-cloze-gap.css +0 -444
  79. package/dist/widgets/webwriter-cloze-gap.js +0 -5727
  80. package/dist/widgets/webwriter-cloze.css +0 -444
  81. package/dist/widgets/webwriter-cloze.js +0 -3653
  82. package/dist/widgets/webwriter-mark.css +0 -444
  83. package/dist/widgets/webwriter-order.css +0 -444
  84. package/dist/widgets/webwriter-pairing-item.css +0 -444
  85. package/dist/widgets/webwriter-pairing.css +0 -444
  86. package/dist/widgets/webwriter-quiz.css +0 -444
  87. package/dist/widgets/webwriter-task-explainer.css +0 -444
  88. package/dist/widgets/webwriter-task-explainer.js +0 -2769
  89. package/dist/widgets/webwriter-task-hint.css +0 -444
  90. package/dist/widgets/webwriter-task-hint.js +0 -2708
  91. package/dist/widgets/webwriter-task.css +0 -444
  92. package/dist/widgets/webwriter-text.css +0 -444
  93. package/localization/bg.xlf +0 -72
  94. package/localization/cs.xlf +0 -72
  95. package/localization/da.xlf +0 -72
  96. package/localization/el.xlf +0 -72
  97. package/localization/et.xlf +0 -72
  98. package/localization/fi.xlf +0 -72
  99. package/localization/generated/bg.ts +0 -31
  100. package/localization/generated/cs.ts +0 -31
  101. package/localization/generated/da.ts +0 -31
  102. package/localization/generated/el.ts +0 -31
  103. package/localization/generated/et.ts +0 -31
  104. package/localization/generated/fi.ts +0 -31
  105. package/localization/generated/hu.ts +0 -31
  106. package/localization/generated/id.ts +0 -31
  107. package/localization/generated/ja.ts +0 -31
  108. package/localization/generated/ko.ts +0 -31
  109. package/localization/generated/lt.ts +0 -31
  110. package/localization/generated/lv.ts +0 -31
  111. package/localization/generated/nb.ts +0 -31
  112. package/localization/generated/pl.ts +0 -31
  113. package/localization/generated/pt-BR.ts +0 -31
  114. package/localization/generated/pt-PT.ts +0 -31
  115. package/localization/generated/ro.ts +0 -31
  116. package/localization/generated/ru.ts +0 -31
  117. package/localization/generated/sk.ts +0 -31
  118. package/localization/generated/sl.ts +0 -31
  119. package/localization/generated/sv.ts +0 -31
  120. package/localization/generated/tr.ts +0 -31
  121. package/localization/generated/uk.ts +0 -31
  122. package/localization/generated/zh-hans.ts +0 -31
  123. package/localization/hu.xlf +0 -72
  124. package/localization/id.xlf +0 -72
  125. package/localization/ja.xlf +0 -72
  126. package/localization/ko.xlf +0 -72
  127. package/localization/lt.xlf +0 -72
  128. package/localization/lv.xlf +0 -72
  129. package/localization/nb.xlf +0 -72
  130. package/localization/pl.xlf +0 -72
  131. package/localization/pt-BR.xlf +0 -72
  132. package/localization/pt-PT.xlf +0 -72
  133. package/localization/ro.xlf +0 -72
  134. package/localization/ru.xlf +0 -72
  135. package/localization/sk.xlf +0 -72
  136. package/localization/sl.xlf +0 -72
  137. package/localization/sv.xlf +0 -72
  138. package/localization/tr.xlf +0 -72
  139. package/localization/uk.xlf +0 -72
  140. package/localization/zh-hans.xlf +0 -72
  141. package/src/lib/combobox.ts +0 -456
  142. package/src/lib/highlighter-fill.svg +0 -6
  143. package/src/snippets/Beispiel-Choice.html +0 -57
  144. package/src/snippets/Beispiel-Mark.html +0 -14
  145. package/src/snippets/Beispiel-Order.html +0 -52
  146. package/src/snippets/Beispiel-Text.html +0 -20
  147. package/src/snippets/blank.html +0 -1
  148. package/src/snippets/choice.html +0 -8
  149. package/src/snippets/cloze.html +0 -4
  150. package/src/snippets/mark.html +0 -4
  151. package/src/snippets/pairing.html +0 -10
  152. package/src/snippets/speech.html +0 -4
  153. package/src/snippets/text.html +0 -4
  154. package/src/snippets/wordsearch.html +0 -4
  155. package/src/widgets/webwriter-choice-item.ts +0 -257
  156. package/src/widgets/webwriter-choice.ts +0 -353
  157. package/src/widgets/webwriter-cloze-gap.ts +0 -216
  158. package/src/widgets/webwriter-cloze.ts +0 -136
  159. package/src/widgets/webwriter-order-item.ts +0 -441
  160. package/src/widgets/webwriter-order.ts +0 -327
  161. package/src/widgets/webwriter-pairing-item.ts +0 -161
  162. package/src/widgets/webwriter-pairing.ts +0 -193
  163. package/src/widgets/webwriter-speech.ts +0 -273
  164. package/src/widgets/webwriter-task-explainer.ts +0 -43
  165. package/src/widgets/webwriter-task-hint.ts +0 -23
@@ -1,555 +1,216 @@
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
- import "@shoelace-style/shoelace/dist/themes/light.css"
22
- import { WebwriterTaskExplainer } from "./webwriter-task-explainer"
23
-
24
- import LOCALIZE from "../../localization/generated"
25
- import {msg} from "@lit/localize"
26
-
27
- async function arrayBufferToDataUrl(buffer: ArrayBuffer | Uint8Array) {
28
- return new Promise(r => {
29
- const reader = new FileReader()
30
- reader.onload = () => r(reader.result as string)
31
- reader.readAsDataURL(new Blob([buffer]))
32
- }) as Promise<string>
1
+ import { msg, str } from "@lit/localize";
2
+ import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js";
3
+ import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js";
4
+ import SlTag from "@shoelace-style/shoelace/dist/components/tag/tag.component.js";
5
+ import { LitElementWw, type OptionDeclaration } from "@webwriter/lit";
6
+ import ArrowDownIcon from "bootstrap-icons/icons/arrow-down.svg";
7
+ import ArrowUpIcon from "bootstrap-icons/icons/arrow-up.svg";
8
+ import WarningIcon from "bootstrap-icons/icons/exclamation-triangle.svg";
9
+ import TrashIcon from "bootstrap-icons/icons/trash.svg";
10
+ import { css, html, nothing } from "lit";
11
+ import { customElement, property, state } from "lit/decorators.js";
12
+ import { classMap } from "lit/directives/class-map.js";
13
+ import type { IWebWriterQuizType } from "../api";
14
+ import { commonStyles } from "../lib/utils";
15
+ import { mutateWithStableSelection } from "../lib/webwriter-quirks";
16
+ import type { WebwriterQuiz } from "./webwriter-quiz";
17
+
18
+ declare global {
19
+ interface HTMLElementTagNameMap {
20
+ "webwriter-task": WebwriterTask;
21
+ }
33
22
  }
34
23
 
35
- async function dataUrlToArrayBuffer(url: string) {
36
- return await (await (await fetch(url)).blob()).arrayBuffer()
37
- }
38
-
39
- function getKeyMaterial(password: string) {
40
- let enc = new TextEncoder();
41
- return window.crypto.subtle.importKey(
42
- "raw",
43
- enc.encode(password),
44
- {name: "PBKDF2"},
45
- false,
46
- ["deriveBits", "deriveKey"]
47
- );
48
- }
49
-
50
- function getKey(keyMaterial: CryptoKey, salt: ArrayBufferView) {
51
- return window.crypto.subtle.deriveKey(
52
- {
53
- "name": "PBKDF2",
54
- salt: salt,
55
- "iterations": 100000,
56
- "hash": "SHA-256"
57
- },
58
- keyMaterial,
59
- { "name": "AES-GCM", "length": 256},
60
- true,
61
- [ "encrypt", "decrypt" ]
62
- )
63
- }
64
-
65
- function romanOrdinal(num: number, capitalize=false) {
66
- let roman = {
67
- m: 1000,
68
- cm: 900,
69
- d: 500,
70
- cd: 400,
71
- c: 100,
72
- xc: 90,
73
- l: 50,
74
- xl: 40,
75
- x: 10,
76
- ix: 9,
77
- v: 5,
78
- iv: 4,
79
- i: 1
80
- };
81
- let str = ''
82
-
83
- for (let i of Object.keys(roman)) {
84
- let q = Math.floor(num / roman[i])
85
- num -= q * roman[i]
86
- str += i.repeat(q)
87
- }
88
-
89
- return capitalize? str.toUpperCase(): str
90
- }
91
-
92
- function alphabeticalOrdinal(num: number, capitalize=false, alphabet="abcdefghijklmnopqrstuvwxyz") {
93
- const a = alphabet[0]
94
- const k = alphabet.length
95
- const str = a.repeat(Math.floor(num / k)) + alphabet[num % k]
96
- return capitalize? str.toUpperCase(): str
97
- }
98
-
99
-
100
-
101
- interface Answer {
102
- solution: any
103
- reportSolution(): void
104
- reset(): void
105
- }
106
-
107
- declare global {interface HTMLElementTagNameMap {
108
- "webwriter-task": WebwriterTask;
109
- }}
110
-
24
+ /**
25
+ * A task describes a single quiz question, which consists of a
26
+ * `<webwriter-task-prompt>`, an optional `<webwriter-quiz-hint>` and an answer
27
+ * of a certain type, such as `<webwriter-choice>`.
28
+ *
29
+ * The `<webwriter-task-prompt>` must be assigned to the `prompt` slot, the
30
+ * `<webwriter-quiz-hint>` must be assigned to the `hint` slot and the answer
31
+ * must be assigned to the default slot.
32
+ */
111
33
  @customElement("webwriter-task")
112
34
  export class WebwriterTask extends LitElementWw {
113
-
114
- localize = LOCALIZE
115
-
116
- static localization = {}
117
-
118
- static scopedElements = {
119
- "sl-icon-button": SlIconButton,
120
- "sl-details": SlDetails,
121
- "sl-popup": SlPopup,
122
- "sl-button": SlButton,
123
- "sl-button-group": SlButtonGroup,
124
- "sl-tab-group": SlTabGroup,
125
- "sl-tab": SlTab,
126
- "sl-tab-panel": SlTabPanel
127
- }
128
-
129
- static styles = css`
130
- :host {
131
- display: flex !important;
132
- flex-direction: column;
133
- gap: 1rem;
134
- position: relative;
135
- z-index: 1;
136
- }
137
-
138
-
139
- :host(:not([hint])) details {
140
- display: none;
141
- }
142
-
143
- sl-tooltip::part(base__popup) {
144
- cursor: text;
145
- }
146
-
147
- #hint-popup {
148
- --arrow-color: var(--sl-color-neutral-700);
149
- z-index: 1000;
150
- }
151
-
152
- #hint-popup::part(popup) {
153
- z-index: 100;
154
- max-width: 200px;
155
- }
156
-
157
- :host(:not([contenteditable=true]):not([contenteditable=""]):not([hint])) #hint {
158
- display: none;
159
- }
160
-
161
- :host(:not([contenteditable=true]):not([contenteditable=""])) .author-only {
162
- display: none;
163
- }
164
-
165
- :host(:is([contenteditable=true], [contenteditable=""])) .user-only {
166
- display: none;
167
- }
168
-
169
- :host(:not([contenteditable=true]):not([contenteditable=""])) {
170
- ::slotted([slot=prompt]:empty) {
171
- display: none;
172
- }
173
- }
174
-
175
- :host(:not([submitted]):not([contenteditable=true]):not([contenteditable=""])) #explainer-group {
176
- display: none;
177
- }
178
-
179
- #task-buttons {
180
- position: absolute;
181
- right: 0;
182
- top: 0;
183
- background: rgba(255, 255, 255, 0.9);
184
- user-select: none;
185
- z-index: 100;
186
- }
187
-
188
- #hint-content {
189
- background: var(--sl-color-neutral-700);
190
- color: var(--sl-color-neutral-50);
191
- min-width: 2ch;
192
- font-size: 0.75rem;
193
- padding: 0.5rem;
194
- border-radius: 4px;
195
- user-select: auto;
196
- }
197
-
198
- ::slotted([slot=explainer]:not([active])) {
199
- display: none !important;
200
- }
201
-
202
- sl-tab-group {
203
- &[data-empty] {
204
- display: none;
205
- }
206
-
207
- &[data-single] sl-tab {
208
- display: none;
209
- }
210
-
211
- & sl-tab::part(base) {
212
- padding: 10px;
213
- }
214
-
215
- &::part(tabs) {
216
- height: 100px;
217
- margin-left: -1px;
218
- z-index: 10;
219
- }
220
-
221
- & ::slotted([name=explainer]) {
222
- height: 100%;
223
- }
224
- }
225
-
226
- header {
227
- display: flex;
228
- flex-direction: row;
229
- gap: 1ch;
230
-
231
- & span:empty {
232
- display: none;
233
- }
234
-
235
- & slot {
236
- display: block;
237
- flex-grow: 1;
238
- }
239
- }
240
-
241
- .user-actions {
242
- & #submit {
243
- flex-grow: 3;
244
- }
245
-
246
- & #reset {
247
- flex-grow: 1;
248
- }
249
- }
250
-
251
- `
252
-
253
- @queryAssignedElements({slot: "hint"})
254
- accessor hints: HTMLElement[]
255
-
256
- get hasHintElement() {
257
- return this.hints.length > 0
258
- }
259
-
260
- get hasHintContent() {
261
- return this.hints.some(hint => hint.innerText.trim() !== "")
262
- }
263
-
264
- @property({type: Boolean, attribute: true, reflect: true})
265
- accessor hint = false
266
-
267
- @property({type: Boolean, state: true, attribute: false, reflect: false})
268
- accessor isChanged = false
269
-
270
- get directSubmit() {
271
- return !this.closest("webwriter-quiz")
272
- }
273
-
274
- @property({type: Boolean, attribute: false, reflect: true})
275
- private set directSubmit(value: boolean) {
276
- return
277
- }
278
-
279
- @property({type: Boolean, attribute: true, reflect: true})
280
- accessor submitted = false
281
-
282
- toggleHint() {
283
- this.hintOpen = !this.hintOpen
284
- if(this.isContentEditable && this.hintOpen) {
285
- this.hint = true
286
- if(!this.hasHintElement) {
287
- const hintEl = this.ownerDocument.createElement("webwriter-task-hint")
288
- hintEl.slot = "hint"
289
- this.answer.insertAdjacentElement("beforebegin", hintEl)
290
- this.ownerDocument.getSelection().setBaseAndExtent(hintEl, 0, hintEl, 0)
291
- }
292
- }
293
- else if(this.isContentEditable && !this.hintOpen) {
294
- if(!this.hasHintContent) {
295
- this.hint = false
296
- this.hintSlotEl.assignedElements().forEach(el => el.remove())
297
- }
298
- }
299
- }
300
-
301
- get explainers(): WebwriterTaskExplainer[] {
302
- return Array.from(this.querySelectorAll("webwriter-task-explainer")) as unknown as WebwriterTaskExplainer[]
303
- }
304
-
305
- toggleExplainers = () => {
306
- if(this.explainers.length) {
307
- this.explainers.forEach(explainer => explainer.remove())
308
- this.activeExplainer = undefined
309
- }
310
- else {
311
- const solutionExplainer = this.ownerDocument.createElement("webwriter-task-explainer")
312
- solutionExplainer.slot = "explainer"
313
- solutionExplainer.id = "solution"
314
- solutionExplainer.active = true/*
315
- const elseExplainer = this.ownerDocument.createElement("webwriter-task-explainer")
316
- elseExplainer.slot = "explainer"
317
- elseExplainer.id = "else"*/
318
- this.append(solutionExplainer)
319
- this.ownerDocument.getSelection().setBaseAndExtent(solutionExplainer, 0, solutionExplainer, 0)
320
- this.requestUpdate()
321
- this.activeExplainer = "solution"
322
- }
323
- }
324
-
325
- @property({attribute: false, state: true})
326
- private accessor hintOpen = false
327
-
328
- @property({attribute: true, reflect: true})
329
- accessor counter: "number" | "roman" | "roman-capitalized" | "alphabetical" | "alphabetical-capitalized"
330
-
331
- get index() {
332
- return [...(this?.parentElement?.children ?? [])].indexOf(this)
333
- }
334
-
335
- get ordinalExpr() {
336
- if(this.index === undefined || this.index === -1) {
337
- return undefined
338
- }
339
- if(this.counter === "number") {
340
- return `${this.index + 1}.`
341
- }
342
- else if(this.counter === "roman") {
343
- return `${romanOrdinal(this.index + 1)}.`
344
- }
345
- else if(this.counter === "roman-capitalized") {
346
- return `${romanOrdinal(this.index + 1, true)}.`
347
- }
348
- else if(this.counter === "alphabetical") {
349
- return `${alphabeticalOrdinal(this.index)}.`
350
- }
351
- else if(this.counter === "alphabetical-capitalized") {
352
- return `${alphabeticalOrdinal(this.index, true)}.`
353
- }
354
- }
355
-
356
- observer: MutationObserver
357
-
358
- connectedCallback(): void {
359
- super.connectedCallback()
360
- this.observer = new MutationObserver(() => this.requestUpdate())
361
- this.parentElement && this.observer.observe(this.parentElement, {childList: true})
362
- this.addEventListener("keydown", (e) => this.handleHintKeydown(e))
363
- }
364
-
365
- protected firstUpdated(_changedProperties: PropertyValues): void {
366
- if(this.isContentEditable) {
367
- this.#decodeSolution()
368
- }
369
- }
370
-
371
-
372
- disconnectedCallback(): void {
373
- super.disconnectedCallback()
374
- this.observer.disconnect()
375
- }
376
-
377
- @property({type: String, attribute: true, reflect: true})
378
- accessor solution: string
379
-
380
- /** Property containing the password currently entered by the author or user */
381
- @property({type: String, attribute: false, reflect: false})
382
- // @option({type: String, label: {_: "Password"}, description: {_: "Password-protects quiz answers"}})
383
- accessor password: string = "B08bxd82SAOf"
384
-
385
- @property({type: String, attribute: true, reflect: true})
386
- accessor salt: string
387
-
388
- @property({type: String, attribute: true, reflect: true})
389
- accessor iv: string
390
-
391
- async #encodeSolution() {
392
- const value = this.answer.solution as any
393
- let keyMaterial = await getKeyMaterial(this.password)
394
- let salt = window.crypto.getRandomValues(new Uint8Array(16))
395
- let iv = window.crypto.getRandomValues(new Uint8Array(12))
396
- let key = await getKey(keyMaterial, salt)
397
- let encoder = new TextEncoder();
398
- let encodedMessage = encoder.encode(JSON.stringify(value))
399
- const solution = await window.crypto.subtle.encrypt(
400
- {name: "AES-GCM", iv},
401
- key,
402
- encodedMessage
403
- )
404
-
405
- this.solution = await arrayBufferToDataUrl(solution)
406
- this.iv = await arrayBufferToDataUrl(iv)
407
- this.salt = await arrayBufferToDataUrl(salt)
408
- }
409
-
410
- async #decodeSolution() {
411
- if(!this.solution) {
412
- return
413
- }
414
- const encodedSolution = await dataUrlToArrayBuffer(this.solution)
415
- const iv = await dataUrlToArrayBuffer(this.iv)
416
- const salt = await dataUrlToArrayBuffer(this.salt)
417
- let keyMaterial = await getKeyMaterial(this.password)
418
- let key = await getKey(keyMaterial, new Uint8Array(salt))
419
- try {
420
- const solutionBuffer = await window.crypto.subtle.decrypt({name: "AES-GCM", iv}, key, encodedSolution)
421
- let decoder = new TextDecoder()
422
- const solution = JSON.parse(decoder.decode(solutionBuffer))
423
- this.answer.solution = solution
424
- }
425
- catch(err) {
426
- console.error(err)
427
- throw new Error("Invalid password")
428
- }
429
- }
430
-
431
- checkSolution() {
432
- const solution = this.#decodeSolution()
433
- return Object.entries(solution).every(([k, v]) => {
434
- return JSON.stringify(this.answer[k]) === JSON.stringify(v)
435
- })
436
- }
437
-
438
- reportSolution() {
439
- // @ts-ignore
440
- this.answer.reportSolution(this.#decodeSolution())
441
- }
442
-
443
- @query("slot:not([name])")
444
- accessor slotEl: HTMLSlotElement
445
-
446
- @query("slot[name=hint]")
447
- accessor hintSlotEl: HTMLSlotElement
448
-
449
- get answer() {
450
- return this.slotEl?.assignedElements()[0] as HTMLElement
451
- }
452
-
453
-
454
-
455
- handleHintSlotChange = (e: Event) => {
456
- if(!this.hasHintElement) {
457
- this.hintOpen = false
458
- this.hint = false
459
- }
460
- }
461
-
462
- handleHintKeydown = (e: KeyboardEvent) => {
463
- // console.log(document.getSelection().anchorOffset === 0, this.hints.includes(document.getSelection().anchorNode.parentElement))
464
- if(e.key === "Backspace" && document.getSelection().anchorOffset === 0 && this.hints.includes(document.getSelection().anchorNode.parentElement)) {
465
- this.hintOpen = false
466
- this.hint = false
467
- }
468
- }
469
-
470
- handleSubmit = async () => {
471
- if(this.answer.freeText === true){
472
- let submitButton: SlButton = this.shadowRoot.getElementById("submit") as SlButton
473
- submitButton.style.visibility = "hidden"
474
- }
475
- await this.#decodeSolution()
476
- this.answer.reportSolution()
477
- this.dispatchEvent(new SubmitEvent("submit", {bubbles: true, composed: true}))
478
- this.activeExplainer = this.explainers[0].id
479
- this.submitted = true
480
- }
481
-
482
- handleReset = () => {
483
- if(this.answer.freeText === true){
484
- let submitButton: SlButton = this.shadowRoot.getElementById("submit") as SlButton
485
- submitButton.style.visibility = "visible"
486
- }
487
- this.answer.reset && this.answer.reset()
488
- this.isChanged = this.answer.freeText === true ? this.isChanged : false
489
- this.submitted = false
490
- }
491
-
492
- handleAnswerChange = async (e: CustomEvent) => {
493
- this.isChanged = true
494
- if(this.isContentEditable) {
495
- this.#encodeSolution()
496
- }
497
- }
498
-
499
- handleSlotChange = (e: Event) => {
500
- this.requestUpdate()
501
- if(this.isContentEditable) {
502
- const solution = this.#decodeSolution() ?? {}
503
- Object.entries(solution).forEach(([k, v]) => {
504
- this.answer[k] = v
505
- })
506
- }
507
- }
508
-
509
- @property()
510
- accessor activeExplainer: string
511
-
512
- selectExplainer(id: string) {
513
- const explainer = this.explainers.find(node => node.id === id)
514
- explainer.active = true
515
- this.explainers.filter(node => node.id !== id).forEach(node => node.active = false)
516
- this.activeExplainer = id
517
- setTimeout(() => this.ownerDocument.getSelection().setBaseAndExtent(explainer, 0, explainer, 0))
518
- }
519
-
520
- get explainerLabels() {
521
- return {
522
- "solution": "Explainer",
523
- "else": "Else"
524
- }
525
- }
526
-
527
- render() {
528
- return html`
529
- <header>
530
- <span>${this.ordinalExpr}</span>
531
- <slot name="prompt"></slot>
532
- <div id="task-buttons">
533
- <sl-icon-button style="display: none" class="author-only" id="feedback" src=${!this.explainers.length? IconPatchCheck: IconPatchCheckFill} @click=${() => this.toggleExplainers()}></sl-icon-button>
534
- <sl-popup id="hint-popup" ?active=${this.hintOpen} placement="left" arrow auto-size shift @selectstart=${e => e.stopImmediatePropagation()}>
535
- <sl-icon-button slot="anchor" id="hint" src=${!this.hasHintContent && !this.hintOpen? IconPatchQuestion: IconPatchQuestionFill} @click=${() => this.toggleHint()}></sl-icon-button>
536
- <div id="hint-content">
537
- <slot name="hint" @slotchange=${this.handleHintSlotChange}></slot>
538
- </div>
539
- </sl-popup>
540
- </div>
541
- </header>
542
- <slot @ww-answer-change=${this.handleAnswerChange} @slotchange=${this.handleSlotChange} ?inert=${this.submitted}></slot>
543
- <sl-tab-group id="explainer-group" placement="end" ?data-empty=${!this.explainers.length} ?data-single=${this.explainers.length === 1}>
544
- ${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>`)}
545
- <slot name="explainer" style=${styleMap({"--ww-placeholder": `"${msg("Explanation")}"`})}></slot>
546
- </sl-tab-group>
547
- ${!this.directSubmit || !this.answer?.reportSolution? null: html`
548
- <sl-button-group class="user-only user-actions">
549
- <sl-button id="submit" @click=${this.handleSubmit}>${this.answer.freeText != true ? msg("Check your answers") : msg("Save answer")}</sl-button>
550
- <sl-button ?disabled=${!this.isChanged && !this.submitted} id="reset" class="user-only" @click=${this.handleReset}>${this.answer.freeText != true ? msg("Try again") : msg("Reset")}</sl-button>
551
- </sl-button-group>
552
- `}
553
- `
554
- }
555
- }
35
+ /** @internal */
36
+ get dynamicOptions(): Record<string, OptionDeclaration> {
37
+ return {
38
+ "points": { type: Number, label: { _: msg("Points") } },
39
+ };
40
+ }
41
+
42
+ /** @internal */
43
+ static scopedElements = {
44
+ "sl-icon-button": SlIconButton,
45
+ "sl-icon": SlIcon,
46
+ "sl-tag": SlTag,
47
+ };
48
+
49
+ static styles = [
50
+ commonStyles,
51
+ css`
52
+ :host {
53
+ display: block;
54
+ }
55
+
56
+ sl-tag {
57
+ margin-top: -5px;
58
+ margin-bottom: -5px;
59
+ }
60
+
61
+ .quiz-header {
62
+ margin-bottom: var(--sl-spacing-3x-small);
63
+
64
+ display: flex;
65
+ align-items: center;
66
+
67
+ .title {
68
+ font-weight: bold;
69
+ }
70
+
71
+ .required-warning {
72
+ margin-left: var(--sl-spacing-small);
73
+
74
+ sl-icon {
75
+ padding-right: var(--sl-spacing-2x-small);
76
+ }
77
+ }
78
+
79
+ .spacer {
80
+ flex-grow: 1;
81
+ }
82
+
83
+ .actions {
84
+ opacity: 0;
85
+ display: flex;
86
+ align-items: center;
87
+
88
+ sl-icon-button::part(base) {
89
+ /* Ensures that icon buttons match the 1.5 line height of the header text */
90
+ padding: 0.25em;
91
+ }
92
+ }
93
+
94
+ .points {
95
+ font-variant-numeric: tabular-nums;
96
+ }
97
+ }
98
+
99
+ :host(:hover) .actions {
100
+ opacity: 1;
101
+ }
102
+ `,
103
+ ];
104
+
105
+ @property({ type: Number, attribute: "points", reflect: true })
106
+ private accessor points = 1;
107
+
108
+ @state() private accessor requiredWarning = false;
109
+
110
+ @state() private accessor score: number | undefined;
111
+
112
+ @state() private accessor gradable = false;
113
+
114
+ private gradabilityUpdate = 0;
115
+
116
+ connectedCallback() {
117
+ super.connectedCallback();
118
+ this.updateGradability();
119
+ }
120
+
121
+ protected willUpdate(changedProperties: Map<PropertyKey, unknown>) {
122
+ if (changedProperties.has("points") && (!Number.isFinite(this.points) || this.points <= 0)) {
123
+ this.points = 1;
124
+ }
125
+ }
126
+
127
+ /** @internal */
128
+ showRequiredWarning(show: boolean) {
129
+ this.requiredWarning = show;
130
+ }
131
+
132
+ /** @internal */
133
+ showScore(score: number | undefined) {
134
+ this.score = score;
135
+ }
136
+
137
+ /** @internal */
138
+ resetFeedback() {
139
+ this.requiredWarning = false;
140
+ this.score = undefined;
141
+ }
142
+
143
+ private formatScore(score: number) {
144
+ return Number(score.toFixed(2));
145
+ }
146
+
147
+ private updateGradability() {
148
+ const update = ++this.gradabilityUpdate;
149
+ const questionTypes = Array.from(this.children).filter(child => !child.slot);
150
+ this.gradable = questionTypes.some(
151
+ child => typeof (child as Partial<IWebWriterQuizType>).checkAnswer === "function",
152
+ );
153
+
154
+ for (const questionType of questionTypes) {
155
+ if (customElements.get(questionType.localName)) continue;
156
+ customElements.whenDefined(questionType.localName).then(() => {
157
+ if (update === this.gradabilityUpdate) this.updateGradability();
158
+ });
159
+ }
160
+ }
161
+
162
+ private QuizHeader() {
163
+ const quiz = this.parentElement as WebwriterQuiz | null;
164
+ if (!quiz || quiz.tagName !== "WEBWRITER-QUIZ") return nothing;
165
+
166
+ const allTasks = Array.from(quiz.children);
167
+ const index = allTasks.indexOf(this);
168
+
169
+ return html`
170
+ <div class=${classMap({ "quiz-header": true })}>
171
+ <div class="title">${msg(str`Question ${index + 1}`)}</div>
172
+ ${this.requiredWarning
173
+ ? html`<sl-tag variant="danger" class="required-warning">
174
+ <sl-icon src=${WarningIcon}></sl-icon> ${msg("Unanswered")}
175
+ </sl-tag>`
176
+ : nothing}
177
+ <div class="spacer"></div>
178
+ <span class="actions author-only">
179
+ <sl-icon-button
180
+ src=${ArrowUpIcon}
181
+ ?disabled=${index === 0}
182
+ @click=${() => mutateWithStableSelection(this, () => allTasks[index - 1].before(this))}
183
+ ></sl-icon-button>
184
+ <sl-icon-button
185
+ src=${ArrowDownIcon}
186
+ ?disabled=${index === allTasks.length - 1}
187
+ @click=${() => mutateWithStableSelection(this, () => allTasks[index + 1].after(this))}
188
+ ></sl-icon-button>
189
+ <sl-icon-button src=${TrashIcon} @click=${() => this.remove()}></sl-icon-button>
190
+ </span>
191
+ ${this.gradable ? this.TaskResult(quiz) : nothing}
192
+ </div>
193
+ `;
194
+ }
195
+
196
+ private TaskResult(quiz: WebwriterQuiz) {
197
+ if (quiz.hidePoints) {
198
+ if (this.score === undefined) return nothing;
199
+ if (this.score === 0) return html`<sl-tag variant="danger">${msg("Incorrect")}</sl-tag>`;
200
+ if (this.score === this.points) return html`<sl-tag variant="success">${msg("Correct")}</sl-tag>`;
201
+ return html`<sl-tag variant="warning">${msg("Partially correct")}</sl-tag>`;
202
+ }
203
+
204
+ return html`<span>
205
+ ${this.score !== undefined ? html`${this.formatScore(this.score)} / ` : nothing}
206
+ <span class="points">${this.points}</span>
207
+ ${this.points === 1 ? msg("point") : msg("points")}
208
+ </span>`;
209
+ }
210
+
211
+ render() {
212
+ return html`${this.QuizHeader()}
213
+ <slot name="prompt"></slot>
214
+ <slot @slotchange=${this.updateGradability}></slot> `;
215
+ }
216
+ }