@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.
- package/LICENSE +7 -0
- package/custom.d.ts +176 -0
- package/dist/widgets/webwriter-choice-item.css +444 -0
- package/dist/widgets/webwriter-choice-item.js +288 -0
- package/dist/widgets/webwriter-choice.css +444 -0
- package/dist/widgets/webwriter-choice.js +327 -0
- package/dist/widgets/webwriter-cloze-gap.css +444 -0
- package/dist/widgets/webwriter-cloze-gap.js +722 -0
- package/dist/widgets/webwriter-cloze.css +444 -0
- package/dist/widgets/webwriter-cloze.js +177 -0
- package/dist/widgets/webwriter-mark.css +444 -0
- package/dist/widgets/webwriter-mark.js +410 -0
- package/dist/widgets/webwriter-order-item.css +444 -0
- package/dist/widgets/webwriter-order-item.js +464 -0
- package/dist/widgets/webwriter-order.css +444 -0
- package/dist/widgets/webwriter-order.js +560 -0
- package/dist/widgets/webwriter-pairing-item.css +444 -0
- package/dist/widgets/webwriter-pairing-item.js +203 -0
- package/dist/widgets/webwriter-pairing.css +444 -0
- package/dist/widgets/webwriter-pairing.js +224 -0
- package/dist/widgets/webwriter-quiz.css +444 -0
- package/dist/widgets/webwriter-quiz.js +323 -0
- package/dist/widgets/webwriter-speech.css +444 -0
- package/dist/widgets/webwriter-speech.js +320 -0
- package/dist/widgets/webwriter-task-explainer.css +444 -0
- package/dist/widgets/webwriter-task-explainer.js +90 -0
- package/dist/widgets/webwriter-task-hint.css +444 -0
- package/dist/widgets/webwriter-task-hint.js +67 -0
- package/dist/widgets/webwriter-task-prompt.css +444 -0
- package/dist/widgets/webwriter-task-prompt.js +68 -0
- package/dist/widgets/webwriter-task.css +444 -0
- package/dist/widgets/webwriter-task.js +546 -0
- package/dist/widgets/webwriter-text.css +444 -0
- package/dist/widgets/webwriter-text.js +174 -0
- package/package.json +199 -0
- package/src/lib/combobox.ts +456 -0
- package/src/lib/highlighter-fill.svg +6 -0
- package/src/snippets/choice.html +8 -0
- package/src/snippets/cloze.html +4 -0
- package/src/snippets/mark.html +4 -0
- package/src/snippets/order.html +8 -0
- package/src/snippets/pairing.html +10 -0
- package/src/snippets/speech.html +4 -0
- package/src/snippets/text.html +4 -0
- package/src/snippets/wordsearch.html +4 -0
- package/src/widgets/webwriter-choice-item.ts +251 -0
- package/src/widgets/webwriter-choice.ts +310 -0
- package/src/widgets/webwriter-cloze-gap.ts +217 -0
- package/src/widgets/webwriter-cloze.ts +136 -0
- package/src/widgets/webwriter-mark.ts +435 -0
- package/src/widgets/webwriter-order-item.ts +445 -0
- package/src/widgets/webwriter-order.ts +273 -0
- package/src/widgets/webwriter-pairing-item.ts +156 -0
- package/src/widgets/webwriter-pairing.ts +188 -0
- package/src/widgets/webwriter-quiz.ts +281 -0
- package/src/widgets/webwriter-speech.ts +268 -0
- package/src/widgets/webwriter-task-explainer.ts +38 -0
- package/src/widgets/webwriter-task-hint.ts +17 -0
- package/src/widgets/webwriter-task-prompt.ts +18 -0
- package/src/widgets/webwriter-task.ts +544 -0
- package/src/widgets/webwriter-text.ts +131 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +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
|
+
}
|
|
544
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {html, css} from "lit"
|
|
2
|
+
import {LitElementWw, option} from "@webwriter/lit"
|
|
3
|
+
import {customElement, property, query} from "lit/decorators.js"
|
|
4
|
+
import {ifDefined} from "lit/directives/if-defined.js"
|
|
5
|
+
|
|
6
|
+
import SlTextarea from "@shoelace-style/shoelace/dist/components/textarea/textarea.component.js"
|
|
7
|
+
import SlInput from "@shoelace-style/shoelace/dist/components/input/input.component.js"
|
|
8
|
+
import "@shoelace-style/shoelace/dist/themes/light.css"
|
|
9
|
+
|
|
10
|
+
declare global {interface HTMLElementTagNameMap {
|
|
11
|
+
"webwriter-text": WebwriterText;
|
|
12
|
+
}}
|
|
13
|
+
|
|
14
|
+
@customElement("webwriter-text")
|
|
15
|
+
export class WebwriterText extends LitElementWw {
|
|
16
|
+
|
|
17
|
+
@property({type: String, attribute: true, reflect: true})
|
|
18
|
+
@option({
|
|
19
|
+
type: "select",
|
|
20
|
+
label: {"en": "Type", "de": "Typ"},
|
|
21
|
+
options: [
|
|
22
|
+
{value: "long-text", label: {"en": "Long Text", "de": "Langer Text"}},
|
|
23
|
+
{value: "text", label: {"en": "Short Text", "de": "Kurzer Text"}},
|
|
24
|
+
{value: "number", label: {"en": "Number", "de": "Zahl"}},
|
|
25
|
+
{value: "date", label: {"en": "Date", "de": "Datum"}},
|
|
26
|
+
{value: "time", label: {"en": "Time", "de": "Uhrzeit"}},
|
|
27
|
+
{value: "datetime-local", label: {"en": "Date & Time", "de": "Datum & Uhrzeit"}}
|
|
28
|
+
]
|
|
29
|
+
})
|
|
30
|
+
accessor type: "date" | "datetime-local" | "number" | "text" | "time" | "long-text" = "long-text"
|
|
31
|
+
|
|
32
|
+
@property({type: String, attribute: true, reflect: true})
|
|
33
|
+
@option()
|
|
34
|
+
accessor placeholder: string
|
|
35
|
+
|
|
36
|
+
@property({type: String, attribute: true, reflect: true})
|
|
37
|
+
accessor value: string
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
@property({type: Number, attribute: true, reflect: true})
|
|
41
|
+
@option({type: Number})
|
|
42
|
+
min: number
|
|
43
|
+
|
|
44
|
+
@property({type: Number, attribute: true, reflect: true})
|
|
45
|
+
@option({type: Number})
|
|
46
|
+
max: number
|
|
47
|
+
|
|
48
|
+
@property({type: Number, attribute: true, reflect: true})
|
|
49
|
+
@option({type: Number})
|
|
50
|
+
step: number
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
static scopedElements = {
|
|
54
|
+
"sl-textarea": SlTextarea,
|
|
55
|
+
"sl-input": SlInput
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static styles = css`
|
|
59
|
+
:host(:is([contenteditable=true], [contenteditable=""])) sl-textarea::part(textarea) {
|
|
60
|
+
color: var(--sl-color-success-700);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
sl-textarea {
|
|
64
|
+
resize: vertical;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
min-height: 40px;
|
|
67
|
+
|
|
68
|
+
&::part(form-control), &::part(form-control-input), &::part(base), &::part(textarea) {
|
|
69
|
+
height: 100%;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
:is(sl-textarea, sl-input)[data-correct]::part(base) {
|
|
74
|
+
background: var(--sl-color-success-200);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#solution {
|
|
78
|
+
padding: 1rem;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#solution[data-correct] {
|
|
82
|
+
background: var(--sl-color-success-200);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#solution:not([data-correct]) {
|
|
86
|
+
background: var(--sl-color-danger-200);
|
|
87
|
+
}
|
|
88
|
+
`
|
|
89
|
+
|
|
90
|
+
handleChange = (e: CustomEvent) => {
|
|
91
|
+
const target = e.target as SlTextarea | SlInput
|
|
92
|
+
if(this.isContentEditable) {
|
|
93
|
+
this.solution = target.value?.trim()
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
this.value = target.value?.trim()
|
|
97
|
+
}
|
|
98
|
+
this.dispatchEvent(new CustomEvent("ww-answer-change", {
|
|
99
|
+
detail: {value: target.value},
|
|
100
|
+
bubbles: true,
|
|
101
|
+
composed: true
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@query("sl-textarea, sl-input")
|
|
106
|
+
accessor input: SlTextarea | SlInput
|
|
107
|
+
|
|
108
|
+
focus() {
|
|
109
|
+
this.input.focus()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
reset() {
|
|
113
|
+
this.solution = undefined
|
|
114
|
+
this.input.value = ""
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
reportSolution() {}
|
|
118
|
+
|
|
119
|
+
@property({type: String, attribute: false, reflect: false})
|
|
120
|
+
accessor solution: string
|
|
121
|
+
|
|
122
|
+
render() {
|
|
123
|
+
const correct = this.solution && this.value?.trim() === this.solution
|
|
124
|
+
const textarea = html`<sl-textarea ?data-correct=${correct} value=${this.isContentEditable? this.solution: this.value} placeholder=${this.placeholder} resize="none" @sl-change=${this.handleChange}></sl-textarea>`
|
|
125
|
+
const input = html`<sl-input ?data-correct=${correct} value=${this.isContentEditable? this.solution: this.value} placeholder=${this.placeholder} type=${this.type} @sl-change=${this.handleChange}></sl-input>`
|
|
126
|
+
return html`
|
|
127
|
+
${this.type === "long-text"? textarea: input}
|
|
128
|
+
${this.solution && !this.isContentEditable && !correct? html`<div id="solution" ?data-correct=${correct}>${this.solution}</div>`: undefined}
|
|
129
|
+
`
|
|
130
|
+
}
|
|
131
|
+
}
|