}
async function dataUrlToArrayBuffer(url: string) {
return await (await (await fetch(url)).blob()).arrayBuffer()
}
function getKeyMaterial(password: string) {
let enc = new TextEncoder();
return window.crypto.subtle.importKey(
"raw",
enc.encode(password),
{name: "PBKDF2"},
false,
["deriveBits", "deriveKey"]
);
}
function getKey(keyMaterial: CryptoKey, salt: ArrayBufferView) {
return window.crypto.subtle.deriveKey(
{
"name": "PBKDF2",
salt: salt,
"iterations": 100000,
"hash": "SHA-256"
},
keyMaterial,
{ "name": "AES-GCM", "length": 256},
true,
[ "encrypt", "decrypt" ]
)
}
function romanOrdinal(num: number, capitalize=false) {
let roman = {
m: 1000,
cm: 900,
d: 500,
cd: 400,
c: 100,
xc: 90,
l: 50,
xl: 40,
x: 10,
ix: 9,
v: 5,
iv: 4,
i: 1
};
let str = ''
for (let i of Object.keys(roman)) {
let q = Math.floor(num / roman[i])
num -= q * roman[i]
str += i.repeat(q)
}
return capitalize? str.toUpperCase(): str
}
function alphabeticalOrdinal(num: number, capitalize=false, alphabet="abcdefghijklmnopqrstuvwxyz") {
const a = alphabet[0]
const k = alphabet.length
const str = a.repeat(Math.floor(num / k)) + alphabet[num % k]
return capitalize? str.toUpperCase(): str
}
interface Answer {
solution: any
reportSolution(): void
reset(): void
}
declare global {interface HTMLElementTagNameMap {
"webwriter-task": WebwriterTask;
}}
@customElement("webwriter-task")
export class WebwriterTask extends LitElementWw {
localize = LOCALIZE
static localization = {}
static scopedElements = {
"sl-icon-button": SlIconButton,
"sl-details": SlDetails,
"sl-popup": SlPopup,
"sl-button": SlButton,
"sl-button-group": SlButtonGroup,
"sl-tab-group": SlTabGroup,
"sl-tab": SlTab,
"sl-tab-panel": SlTabPanel
}
static styles = css`
:host {
display: flex !important;
flex-direction: column;
gap: 1rem;
position: relative;
z-index: 1;
}
:host(:not([hint])) details {
display: none;
}
sl-tooltip::part(base__popup) {
cursor: text;
}
#hint-popup {
--arrow-color: var(--sl-color-neutral-700);
z-index: 1000;
}
#hint-popup::part(popup) {
z-index: 100;
max-width: 200px;
}
:host(:not([contenteditable=true]):not([contenteditable=""]):not([hint])) #hint {
display: none;
}
:host(:not([contenteditable=true]):not([contenteditable=""])) .author-only {
display: none;
}
:host(:is([contenteditable=true], [contenteditable=""])) .user-only {
display: none;
}
:host(:not([contenteditable=true]):not([contenteditable=""])) {
::slotted([slot=prompt]:empty) {
display: none;
}
}
:host(:not([submitted]):not([contenteditable=true]):not([contenteditable=""])) #explainer-group {
display: none;
}
#task-buttons {
position: absolute;
right: 0;
top: 0;
background: rgba(255, 255, 255, 0.9);
user-select: none;
z-index: 100;
}
#hint-content {
background: var(--sl-color-neutral-700);
color: var(--sl-color-neutral-50);
min-width: 2ch;
font-size: 0.75rem;
padding: 0.5rem;
border-radius: 4px;
user-select: auto;
}
::slotted([slot=explainer]:not([active])) {
display: none !important;
}
sl-tab-group {
&[data-empty] {
display: none;
}
&[data-single] sl-tab {
display: none;
}
& sl-tab::part(base) {
padding: 10px;
}
&::part(tabs) {
height: 100px;
margin-left: -1px;
z-index: 10;
}
& ::slotted([name=explainer]) {
height: 100%;
}
}
header {
display: flex;
flex-direction: row;
gap: 1ch;
& span:empty {
display: none;
}
& slot {
display: block;
flex-grow: 1;
}
}
.user-actions {
& #submit {
flex-grow: 3;
}
& #reset {
flex-grow: 1;
}
}
`
@queryAssignedElements({slot: "hint"})
accessor hints: HTMLElement[]
get hasHintElement() {
return this.hints.length > 0
}
get hasHintContent() {
return this.hints.some(hint => hint.innerText.trim() !== "")
}
@property({type: Boolean, attribute: true, reflect: true})
accessor hint = false
@property({type: Boolean, state: true, attribute: false, reflect: false})
accessor isChanged = false
get directSubmit() {
return !this.closest("webwriter-quiz")
}
@property({type: Boolean, attribute: false, reflect: true})
private set directSubmit(value: boolean) {
return
}
@property({type: Boolean, attribute: true, reflect: true})
accessor submitted = false
toggleHint() {
this.hintOpen = !this.hintOpen
if(this.isContentEditable && this.hintOpen) {
this.hint = true
if(!this.hasHintElement) {
const hintEl = this.ownerDocument.createElement("webwriter-task-hint")
hintEl.slot = "hint"
this.answer.insertAdjacentElement("beforebegin", hintEl)
this.ownerDocument.getSelection().setBaseAndExtent(hintEl, 0, hintEl, 0)
}
}
else if(this.isContentEditable && !this.hintOpen) {
if(!this.hasHintContent) {
this.hint = false
this.hintSlotEl.assignedElements().forEach(el => el.remove())
}
}
}
get explainers(): WebwriterTaskExplainer[] {
return Array.from(this.querySelectorAll("webwriter-task-explainer")) as unknown as WebwriterTaskExplainer[]
}
toggleExplainers = () => {
if(this.explainers.length) {
this.explainers.forEach(explainer => explainer.remove())
this.activeExplainer = undefined
}
else {
const solutionExplainer = this.ownerDocument.createElement("webwriter-task-explainer")
solutionExplainer.slot = "explainer"
solutionExplainer.id = "solution"
solutionExplainer.active = true/*
const elseExplainer = this.ownerDocument.createElement("webwriter-task-explainer")
elseExplainer.slot = "explainer"
elseExplainer.id = "else"*/
this.append(solutionExplainer)
this.ownerDocument.getSelection().setBaseAndExtent(solutionExplainer, 0, solutionExplainer, 0)
this.requestUpdate()
this.activeExplainer = "solution"
}
}
@property({attribute: false, state: true})
private accessor hintOpen = false
@property({attribute: true, reflect: true})
accessor counter: "number" | "roman" | "roman-capitalized" | "alphabetical" | "alphabetical-capitalized"
get index() {
return [...(this?.parentElement?.children ?? [])].indexOf(this)
}
get ordinalExpr() {
if(this.index === undefined || this.index === -1) {
return undefined
}
if(this.counter === "number") {
return `${this.index + 1}.`
}
else if(this.counter === "roman") {
return `${romanOrdinal(this.index + 1)}.`
}
else if(this.counter === "roman-capitalized") {
return `${romanOrdinal(this.index + 1, true)}.`
}
else if(this.counter === "alphabetical") {
return `${alphabeticalOrdinal(this.index)}.`
}
else if(this.counter === "alphabetical-capitalized") {
return `${alphabeticalOrdinal(this.index, true)}.`
}
}
observer: MutationObserver
connectedCallback(): void {
super.connectedCallback()
this.observer = new MutationObserver(() => this.requestUpdate())
this.parentElement && this.observer.observe(this.parentElement, {childList: true})
this.addEventListener("keydown", (e) => this.handleHintKeydown(e))
}
protected firstUpdated(_changedProperties: PropertyValues): void {
if(this.isContentEditable) {
this.#decodeSolution()
}
}
disconnectedCallback(): void {
super.disconnectedCallback()
this.observer.disconnect()
}
@property({type: String, attribute: true, reflect: true})
accessor solution: string
/** Property containing the password currently entered by the author or user */
@property({type: String, attribute: false, reflect: false})
// @option({type: String, label: {_: "Password"}, description: {_: "Password-protects quiz answers"}})
accessor password: string = "B08bxd82SAOf"
@property({type: String, attribute: true, reflect: true})
accessor salt: string
@property({type: String, attribute: true, reflect: true})
accessor iv: string
async #encodeSolution() {
const value = this.answer.solution as any
let keyMaterial = await getKeyMaterial(this.password)
let salt = window.crypto.getRandomValues(new Uint8Array(16))
let iv = window.crypto.getRandomValues(new Uint8Array(12))
let key = await getKey(keyMaterial, salt)
let encoder = new TextEncoder();
let encodedMessage = encoder.encode(JSON.stringify(value))
const solution = await window.crypto.subtle.encrypt(
{name: "AES-GCM", iv},
key,
encodedMessage
)
this.solution = await arrayBufferToDataUrl(solution)
this.iv = await arrayBufferToDataUrl(iv)
this.salt = await arrayBufferToDataUrl(salt)
}
async #decodeSolution() {
if(!this.solution) {
return
}
const encodedSolution = await dataUrlToArrayBuffer(this.solution)
const iv = await dataUrlToArrayBuffer(this.iv)
const salt = await dataUrlToArrayBuffer(this.salt)
let keyMaterial = await getKeyMaterial(this.password)
let key = await getKey(keyMaterial, new Uint8Array(salt))
try {
const solutionBuffer = await window.crypto.subtle.decrypt({name: "AES-GCM", iv}, key, encodedSolution)
let decoder = new TextDecoder()
const solution = JSON.parse(decoder.decode(solutionBuffer))
this.answer.solution = solution
}
catch(err) {
console.error(err)
throw new Error("Invalid password")
}
}
checkSolution() {
const solution = this.#decodeSolution()
return Object.entries(solution).every(([k, v]) => {
return JSON.stringify(this.answer[k]) === JSON.stringify(v)
})
}
reportSolution() {
// @ts-ignore
this.answer.reportSolution(this.#decodeSolution())
}
@query("slot:not([name])")
accessor slotEl: HTMLSlotElement
@query("slot[name=hint]")
accessor hintSlotEl: HTMLSlotElement
get answer() {
return this.slotEl?.assignedElements()[0] as HTMLElement
}
handleHintSlotChange = (e: Event) => {
if(!this.hasHintElement) {
this.hintOpen = false
this.hint = false
}
}
handleHintKeydown = (e: KeyboardEvent) => {
// console.log(document.getSelection().anchorOffset === 0, this.hints.includes(document.getSelection().anchorNode.parentElement))
if(e.key === "Backspace" && document.getSelection().anchorOffset === 0 && this.hints.includes(document.getSelection().anchorNode.parentElement)) {
this.hintOpen = false
this.hint = false
}
}
handleSubmit = async () => {
if(this.answer.freeText === true){
let submitButton: SlButton = this.shadowRoot.getElementById("submit") as SlButton
submitButton.style.visibility = "hidden"
}
await this.#decodeSolution()
this.answer.reportSolution()
this.dispatchEvent(new SubmitEvent("submit", {bubbles: true, composed: true}))
this.activeExplainer = this.explainers[0].id
this.submitted = true
}
handleReset = () => {
if(this.answer.freeText === true){
let submitButton: SlButton = this.shadowRoot.getElementById("submit") as SlButton
submitButton.style.visibility = "visible"
}
this.answer.reset && this.answer.reset()
this.isChanged = this.answer.freeText === true ? this.isChanged : false
this.submitted = false
}
handleAnswerChange = async (e: CustomEvent) => {
this.isChanged = true
if(this.isContentEditable) {
this.#encodeSolution()
}
}
handleSlotChange = (e: Event) => {
this.requestUpdate()
if(this.isContentEditable) {
const solution = this.#decodeSolution() ?? {}
Object.entries(solution).forEach(([k, v]) => {
this.answer[k] = v
})
}
}
@property()
accessor activeExplainer: string
selectExplainer(id: string) {
const explainer = this.explainers.find(node => node.id === id)
explainer.active = true
this.explainers.filter(node => node.id !== id).forEach(node => node.active = false)
this.activeExplainer = id
setTimeout(() => this.ownerDocument.getSelection().setBaseAndExtent(explainer, 0, explainer, 0))
}
get explainerLabels() {
return {
"solution": "Explainer",
"else": "Else"
}
}
render() {
return html`
${this.explainers.map((explainer, i) => html` this.selectExplainer(explainer.id)}>${this.explainerLabels[explainer.id] ?? explainer.id}`)}
${!this.directSubmit || !this.answer?.reportSolution? null: html`
${this.answer.freeText != true ? msg("Check your answers") : msg("Save answer")}
${this.answer.freeText != true ? msg("Try again") : msg("Reset")}
`}
`
}
}