@webwriter/quiz 1.1.3 → 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.
- package/.github/workflows/publish.yml +8 -0
- package/LICENSE +6 -6
- package/README.md +778 -0
- package/assets/icons/grip-horizontal.svg +8 -0
- package/assets/icons/grip-vertical.svg +8 -0
- package/assets/icons/truefalse.svg +6 -0
- package/custom-elements.json +2779 -0
- package/custom.d.ts +180 -176
- package/dist/api/index.d.ts +27 -0
- package/dist/api/index.js +40 -0
- package/dist/migrate.js +1434 -0
- package/dist/widgets/webwriter-choice-item.js +4036 -3895
- package/dist/widgets/webwriter-choice.js +2269 -5821
- package/dist/widgets/webwriter-gap-item.js +3987 -0
- package/dist/widgets/webwriter-gap.js +2908 -0
- package/dist/widgets/webwriter-mark.js +4335 -3489
- package/dist/widgets/webwriter-order-item.js +4014 -3306
- package/dist/widgets/webwriter-order.js +2944 -4386
- package/dist/widgets/webwriter-pairing-item.js +1058 -2421
- package/dist/widgets/webwriter-pairing.js +2993 -4383
- package/dist/widgets/webwriter-quiz-hint.js +2469 -0
- package/dist/widgets/webwriter-quiz.js +4933 -6099
- package/dist/widgets/webwriter-speech.js +6077 -5230
- package/dist/widgets/webwriter-task-prompt.js +4775 -2165
- package/dist/widgets/webwriter-task.js +2364 -8382
- package/dist/widgets/webwriter-text.js +4169 -4337
- package/dist/widgets/webwriter-true-false.js +4674 -0
- package/editing-config.json +106 -0
- package/lit-localize.json +21 -15
- package/localization/de.xlf +407 -74
- package/localization/es.xlf +407 -69
- package/localization/fr.xlf +407 -69
- package/localization/generated/de.ts +112 -29
- package/localization/generated/es.ts +112 -29
- package/localization/generated/fr.ts +112 -29
- package/localization/generated/index.d.ts +5 -0
- package/localization/generated/index.js +2 -2
- package/localization/generated/it.ts +112 -29
- package/localization/generated/locale-codes.js +31 -79
- package/localization/generated/nl.ts +112 -29
- package/localization/it.xlf +407 -69
- package/localization/nl.xlf +407 -69
- package/package.json +119 -252
- package/scripts/apply-translations.mjs +97 -0
- package/src/api/index.ts +72 -0
- package/src/lib/artboard.ts +686 -0
- package/src/lib/draggable-grid.ts +432 -0
- package/src/lib/encrypted-property.ts +202 -0
- package/src/lib/hint-popup.ts +152 -0
- package/src/lib/utils.ts +97 -0
- package/src/lib/webwriter-quirks.ts +121 -0
- package/src/migrate/index.ts +622 -0
- package/src/migrate/legacy-solution.ts +362 -0
- package/src/snippets/order.html +9 -8
- package/src/widgets/choice/webwriter-choice-item.ts +316 -0
- package/src/widgets/choice/webwriter-choice.ts +328 -0
- package/src/widgets/choice/webwriter-true-false.ts +113 -0
- package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
- package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
- package/src/widgets/dnd/webwriter-dnd.ts +36 -0
- package/src/widgets/gap/webwriter-gap-item.ts +250 -0
- package/src/widgets/gap/webwriter-gap.ts +447 -0
- package/src/widgets/order/webwriter-order-item.ts +243 -0
- package/src/widgets/order/webwriter-order.ts +282 -0
- package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
- package/src/widgets/pairing/webwriter-pairing.ts +934 -0
- package/src/widgets/speech/audio-player.ts +325 -0
- package/src/widgets/speech/audio-recorder.ts +285 -0
- package/src/widgets/speech/waveform.ts +113 -0
- package/src/widgets/speech/webwriter-speech.ts +87 -0
- package/src/widgets/webwriter-mark.ts +374 -432
- package/src/widgets/webwriter-quiz-hint.ts +36 -0
- package/src/widgets/webwriter-quiz.ts +320 -282
- package/src/widgets/webwriter-task-prompt.ts +49 -24
- package/src/widgets/webwriter-task.ts +216 -547
- package/src/widgets/webwriter-text.ts +291 -156
- package/tsconfig.api.json +12 -0
- package/tsconfig.json +8 -7
- package/dist/widgets/webwriter-choice.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.js +0 -5866
- package/dist/widgets/webwriter-cloze.css +0 -444
- package/dist/widgets/webwriter-cloze.js +0 -3786
- package/dist/widgets/webwriter-mark.css +0 -444
- package/dist/widgets/webwriter-order.css +0 -444
- package/dist/widgets/webwriter-pairing-item.css +0 -444
- package/dist/widgets/webwriter-pairing.css +0 -444
- package/dist/widgets/webwriter-quiz.css +0 -444
- package/dist/widgets/webwriter-task-explainer.css +0 -444
- package/dist/widgets/webwriter-task-explainer.js +0 -2872
- package/dist/widgets/webwriter-task-hint.css +0 -444
- package/dist/widgets/webwriter-task-hint.js +0 -2811
- package/dist/widgets/webwriter-task.css +0 -444
- package/dist/widgets/webwriter-text.css +0 -444
- package/localization/bg.xlf +0 -69
- package/localization/cs.xlf +0 -69
- package/localization/da.xlf +0 -69
- package/localization/el.xlf +0 -69
- package/localization/et.xlf +0 -69
- package/localization/fi.xlf +0 -69
- package/localization/generated/bg.ts +0 -30
- package/localization/generated/cs.ts +0 -30
- package/localization/generated/da.ts +0 -30
- package/localization/generated/el.ts +0 -30
- package/localization/generated/et.ts +0 -30
- package/localization/generated/fi.ts +0 -30
- package/localization/generated/hu.ts +0 -30
- package/localization/generated/id.ts +0 -30
- package/localization/generated/ja.ts +0 -30
- package/localization/generated/ko.ts +0 -30
- package/localization/generated/lt.ts +0 -30
- package/localization/generated/lv.ts +0 -30
- package/localization/generated/nb.ts +0 -30
- package/localization/generated/pl.ts +0 -30
- package/localization/generated/pt-BR.ts +0 -30
- package/localization/generated/pt-PT.ts +0 -30
- package/localization/generated/ro.ts +0 -30
- package/localization/generated/ru.ts +0 -30
- package/localization/generated/sk.ts +0 -30
- package/localization/generated/sl.ts +0 -30
- package/localization/generated/sv.ts +0 -30
- package/localization/generated/tr.ts +0 -30
- package/localization/generated/uk.ts +0 -30
- package/localization/generated/zh-hans.ts +0 -30
- package/localization/hu.xlf +0 -69
- package/localization/id.xlf +0 -69
- package/localization/ja.xlf +0 -69
- package/localization/ko.xlf +0 -69
- package/localization/lt.xlf +0 -69
- package/localization/lv.xlf +0 -69
- package/localization/nb.xlf +0 -69
- package/localization/pl.xlf +0 -69
- package/localization/pt-BR.xlf +0 -69
- package/localization/pt-PT.xlf +0 -69
- package/localization/ro.xlf +0 -69
- package/localization/ru.xlf +0 -69
- package/localization/sk.xlf +0 -69
- package/localization/sl.xlf +0 -69
- package/localization/sv.xlf +0 -69
- package/localization/tr.xlf +0 -69
- package/localization/uk.xlf +0 -69
- package/localization/zh-hans.xlf +0 -69
- package/src/lib/combobox.ts +0 -456
- package/src/lib/highlighter-fill.svg +0 -6
- package/src/snippets/Beispiel-Choice.html +0 -57
- package/src/snippets/Beispiel-Mark.html +0 -14
- package/src/snippets/Beispiel-Order.html +0 -52
- package/src/snippets/Beispiel-Text.html +0 -20
- package/src/snippets/blank.html +0 -1
- package/src/snippets/choice.html +0 -8
- package/src/snippets/cloze.html +0 -4
- package/src/snippets/mark.html +0 -4
- package/src/snippets/pairing.html +0 -10
- package/src/snippets/speech.html +0 -4
- package/src/snippets/text.html +0 -4
- package/src/snippets/wordsearch.html +0 -4
- package/src/widgets/webwriter-choice-item.ts +0 -254
- package/src/widgets/webwriter-choice.ts +0 -353
- package/src/widgets/webwriter-cloze-gap.ts +0 -216
- package/src/widgets/webwriter-cloze.ts +0 -136
- package/src/widgets/webwriter-order-item.ts +0 -441
- package/src/widgets/webwriter-order.ts +0 -327
- package/src/widgets/webwriter-pairing-item.ts +0 -161
- package/src/widgets/webwriter-pairing.ts +0 -193
- package/src/widgets/webwriter-speech.ts +0 -273
- package/src/widgets/webwriter-task-explainer.ts +0 -43
- package/src/widgets/webwriter-task-hint.ts +0 -23
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { msg } from "@lit/localize";
|
|
2
|
+
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js";
|
|
3
|
+
import SlInput from "@shoelace-style/shoelace/dist/components/input/input.component.js";
|
|
4
|
+
import { LitElementWw, OptionDeclaration } from "@webwriter/lit";
|
|
5
|
+
import XIcon from "bootstrap-icons/icons/x.svg";
|
|
6
|
+
import { css, html, nothing } from "lit";
|
|
7
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
8
|
+
import { createRef, ref } from "lit/directives/ref.js";
|
|
9
|
+
import type { GapMode } from "./webwriter-gap";
|
|
10
|
+
|
|
11
|
+
declare global {
|
|
12
|
+
interface HTMLElementTagNameMap {
|
|
13
|
+
"webwriter-gap-item": WebwriterGapItem;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A single gap of a `<webwriter-gap>`, whose content is the correct answer.
|
|
19
|
+
*
|
|
20
|
+
* It must be placed inside the text assigned to the default slot of its parent
|
|
21
|
+
* element.
|
|
22
|
+
*/
|
|
23
|
+
@customElement("webwriter-gap-item")
|
|
24
|
+
export class WebwriterGapItem extends LitElementWw {
|
|
25
|
+
/** @internal */
|
|
26
|
+
get dynamicOptions(): Record<string, OptionDeclaration> {
|
|
27
|
+
const options: Record<string, OptionDeclaration> = {};
|
|
28
|
+
if (this.mode === "input") {
|
|
29
|
+
options.placeholder = { type: "string", label: { _: msg("Placeholder") } };
|
|
30
|
+
}
|
|
31
|
+
return options;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mirrors the mode of the parent gap, which keeps it in sync.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
@property({ attribute: false })
|
|
39
|
+
accessor mode: GapMode | undefined = undefined;
|
|
40
|
+
|
|
41
|
+
/** @internal */
|
|
42
|
+
@state() accessor answer = "";
|
|
43
|
+
/** @internal */
|
|
44
|
+
@state() accessor inputAnswer = "";
|
|
45
|
+
/** @internal */
|
|
46
|
+
@state() accessor graded = false;
|
|
47
|
+
/** @internal */
|
|
48
|
+
@state() accessor feedback: { correct: boolean; answer: string; solution: string } | null = null;
|
|
49
|
+
/** @internal */
|
|
50
|
+
@state() accessor answerSelected = false;
|
|
51
|
+
/** @internal */
|
|
52
|
+
@state() accessor dropActive = false;
|
|
53
|
+
|
|
54
|
+
private slotRef = createRef<HTMLSlotElement>();
|
|
55
|
+
|
|
56
|
+
/** @internal */
|
|
57
|
+
static scopedElements = {
|
|
58
|
+
"sl-icon-button": SlIconButton,
|
|
59
|
+
"sl-input": SlInput,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The placeholder shown while the gap is empty. Only applies in `input` mode.
|
|
64
|
+
*/
|
|
65
|
+
@property({ type: String, attribute: "placeholder", reflect: true })
|
|
66
|
+
accessor placeholder: string = "";
|
|
67
|
+
|
|
68
|
+
static styles = css`
|
|
69
|
+
:host {
|
|
70
|
+
--outer-line-height: 2.3;
|
|
71
|
+
--inner-line-height: 2;
|
|
72
|
+
--inner-height: calc(var(--inner-line-height) * 1em);
|
|
73
|
+
display: inline-block !important;
|
|
74
|
+
outline: none !important;
|
|
75
|
+
vertical-align: baseline;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.container-base {
|
|
79
|
+
height: var(--inner-height);
|
|
80
|
+
line-height: var(--outer-line-height);
|
|
81
|
+
box-sizing: border-box;
|
|
82
|
+
border: 1px solid transparent;
|
|
83
|
+
border-radius: var(--sl-border-radius-medium);
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.container-input {
|
|
89
|
+
background-color: var(--sl-input-background-color);
|
|
90
|
+
border-color: var(--sl-input-border-color);
|
|
91
|
+
padding-left: var(--sl-spacing-x-small);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.container-placeholder {
|
|
95
|
+
background-color: var(--sl-color-neutral-100);
|
|
96
|
+
position: relative;
|
|
97
|
+
border-color: var(--sl-color-neutral-300);
|
|
98
|
+
min-width: 8em;
|
|
99
|
+
padding: 0 var(--sl-spacing-x-small);
|
|
100
|
+
height: 2em;
|
|
101
|
+
white-space: nowrap;
|
|
102
|
+
font: inherit;
|
|
103
|
+
color: inherit;
|
|
104
|
+
justify-content: center;
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
touch-action: none;
|
|
107
|
+
user-select: none;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.container-placeholder::before {
|
|
111
|
+
content: "";
|
|
112
|
+
position: absolute;
|
|
113
|
+
inset: -4px;
|
|
114
|
+
border: 2px dotted var(--sl-color-primary-400);
|
|
115
|
+
border-radius: calc(var(--sl-border-radius-medium) + 3px);
|
|
116
|
+
pointer-events: none;
|
|
117
|
+
opacity: 0;
|
|
118
|
+
transition: opacity var(--sl-transition-fast);
|
|
119
|
+
z-index: 2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.container-placeholder.active::before {
|
|
123
|
+
opacity: 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@media (prefers-reduced-motion: reduce) {
|
|
127
|
+
.container-placeholder::before { transition: none; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.container-placeholder.filled {
|
|
131
|
+
background-color: var(--sl-color-neutral-0);
|
|
132
|
+
min-width: 0;
|
|
133
|
+
width: max-content;
|
|
134
|
+
cursor: grab;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.feedback {
|
|
138
|
+
padding: 0 var(--sl-spacing-x-small);
|
|
139
|
+
gap: var(--sl-spacing-x-small);
|
|
140
|
+
width: max-content;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.feedback.correct {
|
|
144
|
+
color: var(--sl-color-success-700);
|
|
145
|
+
background: var(--sl-color-success-50);
|
|
146
|
+
border-color: var(--sl-color-success-600);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.feedback.incorrect {
|
|
150
|
+
color: var(--sl-color-danger-700);
|
|
151
|
+
background: var(--sl-color-danger-50);
|
|
152
|
+
border-color: var(--sl-color-danger-600);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.correction { color: var(--sl-color-success-700); }
|
|
156
|
+
.container-placeholder:disabled { cursor: default; }
|
|
157
|
+
|
|
158
|
+
sl-input {
|
|
159
|
+
--sl-input-height-medium: var(--inner-height);
|
|
160
|
+
line-height: var(--inner-line-height);
|
|
161
|
+
font: inherit;
|
|
162
|
+
|
|
163
|
+
&::part(form-control),
|
|
164
|
+
&::part(form-control-input),
|
|
165
|
+
&::part(base) {
|
|
166
|
+
display: flex;
|
|
167
|
+
align-items: baseline;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
&::part(input),
|
|
171
|
+
&::part(base) {
|
|
172
|
+
font: inherit;
|
|
173
|
+
color: inherit;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
&::part(input) {
|
|
177
|
+
padding: 0 var(--sl-spacing-x-small);
|
|
178
|
+
/* The base paints the rounded background; this control must not cover it. */
|
|
179
|
+
background: transparent;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
&::part(base) {
|
|
183
|
+
vertical-align: baseline;
|
|
184
|
+
overflow: visible;
|
|
185
|
+
letter-spacing: inherit;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
private renderEditable() {
|
|
191
|
+
return html`<div class="container-base container-input">
|
|
192
|
+
<slot
|
|
193
|
+
${ref(this.slotRef)}
|
|
194
|
+
@slotchange=${(event: Event) => {
|
|
195
|
+
const slot = event.target as HTMLSlotElement;
|
|
196
|
+
if (slot.assignedNodes().length == 0) this.remove();
|
|
197
|
+
}}
|
|
198
|
+
></slot>
|
|
199
|
+
<sl-icon-button
|
|
200
|
+
src=${XIcon}
|
|
201
|
+
@click=${() => {
|
|
202
|
+
const slot = this.slotRef.value;
|
|
203
|
+
if (!slot) return;
|
|
204
|
+
this.replaceWith(...slot.assignedNodes());
|
|
205
|
+
}}
|
|
206
|
+
></sl-icon-button>
|
|
207
|
+
</div>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private renderInput() {
|
|
211
|
+
return html`<sl-input placeholder=${this.placeholder} .value=${this.inputAnswer} ?disabled=${this.graded}
|
|
212
|
+
@sl-input=${(event: Event) => {
|
|
213
|
+
this.inputAnswer = (event.target as SlInput).value;
|
|
214
|
+
}}
|
|
215
|
+
></sl-input>`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private renderDragAndDrop() {
|
|
219
|
+
return html`<button
|
|
220
|
+
type="button"
|
|
221
|
+
?disabled=${this.graded}
|
|
222
|
+
class="container-base container-placeholder ${this.dropActive ? "active" : ""} ${this.answer ? "filled" : ""}"
|
|
223
|
+
aria-label=${this.answer || msg("Empty gap")}
|
|
224
|
+
aria-pressed=${this.answerSelected}
|
|
225
|
+
@pointerdown=${(event: PointerEvent) => this.closest("webwriter-gap")?.startGapDrag(event, this)}
|
|
226
|
+
@click=${() => this.closest("webwriter-gap")?.activateGap(this)}
|
|
227
|
+
>
|
|
228
|
+
${this.answer || "\u00a0"}
|
|
229
|
+
</button>`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
render() {
|
|
233
|
+
if (this.isContentEditable) return this.renderEditable();
|
|
234
|
+
if (this.feedback) {
|
|
235
|
+
const { correct, answer, solution } = this.feedback;
|
|
236
|
+
return html`<span class="container-base feedback ${correct ? "correct" : "incorrect"}" role="status">
|
|
237
|
+
${correct ? answer : html`
|
|
238
|
+
<del aria-label=${msg("Your answer")}>${answer || msg("No answer")}</del>
|
|
239
|
+
<span class="correction" aria-label=${msg("Correct answer")}>${solution}</span>
|
|
240
|
+
`}
|
|
241
|
+
</span>`;
|
|
242
|
+
}
|
|
243
|
+
if (this.mode === "input") return this.renderInput();
|
|
244
|
+
if (this.mode === "drag-and-drop") return this.renderDragAndDrop();
|
|
245
|
+
|
|
246
|
+
// An item that is not in a gap yet has no mode, and renders nothing until it is.
|
|
247
|
+
if (this.mode !== undefined) console.warn("[webwriter-gap-item] Unknown mode:", this.mode);
|
|
248
|
+
return nothing;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { msg } from "@lit/localize";
|
|
2
|
+
import { ActionDeclaration, LitElementWw, OptionDeclaration } from "@webwriter/lit";
|
|
3
|
+
import InputCursorIcon from "bootstrap-icons/icons/input-cursor.svg";
|
|
4
|
+
import { css, html, nothing } from "lit";
|
|
5
|
+
import type { WebwriterGapItem } from "./webwriter-gap-item";
|
|
6
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
7
|
+
import { styleMap } from "lit/directives/style-map.js";
|
|
8
|
+
import LOCALIZE from "../../../localization/generated";
|
|
9
|
+
import { name as packageId } from "../../../package.json";
|
|
10
|
+
import { IWebWriterQuizType, registerQuizType } from "../../api";
|
|
11
|
+
import { isWebWriter } from "../../lib/webwriter-quirks";
|
|
12
|
+
import { randomizeArray } from "../../lib/utils";
|
|
13
|
+
|
|
14
|
+
declare global {
|
|
15
|
+
interface HTMLElementTagNameMap {
|
|
16
|
+
"webwriter-gap": WebwriterGap;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type GapMode = "input" | "drag-and-drop";
|
|
21
|
+
|
|
22
|
+
registerQuizType({
|
|
23
|
+
packageId,
|
|
24
|
+
widgetPosition: 30,
|
|
25
|
+
id: "webwriter-gap",
|
|
26
|
+
getName: () => msg("Gap"),
|
|
27
|
+
icon: InputCursorIcon,
|
|
28
|
+
createInstance: () => document.createElement("webwriter-gap"),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An answer where learners fill the gaps in a text.
|
|
33
|
+
*
|
|
34
|
+
* The text must be assigned to the default slot, with a `<webwriter-gap-item>`
|
|
35
|
+
* in place of each gap.
|
|
36
|
+
*/
|
|
37
|
+
@customElement("webwriter-gap")
|
|
38
|
+
export class WebwriterGap extends LitElementWw implements IWebWriterQuizType {
|
|
39
|
+
/** @internal */
|
|
40
|
+
get dynamicActions() {
|
|
41
|
+
const options: Record<string, ActionDeclaration> = {
|
|
42
|
+
insertGapAtSelection: { label: { _: msg("Insert gap at selection") } },
|
|
43
|
+
};
|
|
44
|
+
return options;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @internal */
|
|
48
|
+
get dynamicOptions(): Record<string, OptionDeclaration> {
|
|
49
|
+
const options: Record<string, OptionDeclaration> = {
|
|
50
|
+
mode: {
|
|
51
|
+
type: "select",
|
|
52
|
+
label: { _: msg("Mode") },
|
|
53
|
+
options: [
|
|
54
|
+
{ value: "input", label: { _: msg("Freeform input") } },
|
|
55
|
+
{ value: "drag-and-drop", label: { _: msg("Drag and drop") } },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
if (this.mode === "input") {
|
|
60
|
+
options["ignore-case"] = { type: "boolean", label: { _: msg("Ignore case") } };
|
|
61
|
+
}
|
|
62
|
+
return options;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @internal */
|
|
66
|
+
localize = LOCALIZE;
|
|
67
|
+
|
|
68
|
+
/** @internal */
|
|
69
|
+
static scopedElements = {};
|
|
70
|
+
|
|
71
|
+
static styles = css`
|
|
72
|
+
:host {
|
|
73
|
+
display: block !important;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
slot {
|
|
77
|
+
display: block;
|
|
78
|
+
cursor: text;
|
|
79
|
+
width: 100%;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.choices {
|
|
83
|
+
display: flex;
|
|
84
|
+
flex-wrap: wrap;
|
|
85
|
+
gap: 0.5em;
|
|
86
|
+
margin-top: 1em;
|
|
87
|
+
min-height: 2em;
|
|
88
|
+
align-items: flex-start;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.choice {
|
|
92
|
+
font: inherit;
|
|
93
|
+
color: inherit;
|
|
94
|
+
padding: 0 var(--sl-spacing-x-small);
|
|
95
|
+
height: 2em;
|
|
96
|
+
box-sizing: border-box;
|
|
97
|
+
display: inline-flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
line-height: normal;
|
|
102
|
+
border: 1px solid var(--sl-color-neutral-300);
|
|
103
|
+
border-radius: var(--sl-border-radius-medium);
|
|
104
|
+
background: var(--sl-color-neutral-0);
|
|
105
|
+
position: relative;
|
|
106
|
+
cursor: grab;
|
|
107
|
+
touch-action: none;
|
|
108
|
+
user-select: none;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* The chip that follows the pointer during a drag. Styled as a choice, so that the
|
|
112
|
+
only thing left to set imperatively is its position and size. */
|
|
113
|
+
.choice.ghost {
|
|
114
|
+
position: fixed;
|
|
115
|
+
left: 0;
|
|
116
|
+
top: 0;
|
|
117
|
+
margin: 0;
|
|
118
|
+
z-index: 2147483647;
|
|
119
|
+
pointer-events: none;
|
|
120
|
+
display: none;
|
|
121
|
+
cursor: grabbing;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
::slotted(*) {
|
|
125
|
+
line-height: 2.3;
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How learners fill the gaps.
|
|
131
|
+
*
|
|
132
|
+
* - `input`: Each gap is a text field learners type into.
|
|
133
|
+
* - `drag-and-drop`: The gaps are filled from a shared pool of draggable answers.
|
|
134
|
+
*/
|
|
135
|
+
@property({ type: String, attribute: true, reflect: true })
|
|
136
|
+
accessor mode: GapMode = "input";
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether answers are compared to the solution case-insensitively. Only applies in `input` mode.
|
|
140
|
+
*/
|
|
141
|
+
@property({ type: Boolean, attribute: "ignore-case", reflect: true })
|
|
142
|
+
accessor ignoreCase = false;
|
|
143
|
+
|
|
144
|
+
@state() private accessor graded = false;
|
|
145
|
+
|
|
146
|
+
@state()
|
|
147
|
+
private accessor items: WebwriterGapItem[] = [];
|
|
148
|
+
private choices: WebwriterGapItem[] = [];
|
|
149
|
+
private placements = new Map<WebwriterGapItem, WebwriterGapItem>();
|
|
150
|
+
private selected: WebwriterGapItem | null = null;
|
|
151
|
+
private observer = new MutationObserver(() => this.syncItems());
|
|
152
|
+
private drag: {
|
|
153
|
+
choice: WebwriterGapItem;
|
|
154
|
+
pointerId: number;
|
|
155
|
+
x: number;
|
|
156
|
+
y: number;
|
|
157
|
+
offsetX: number;
|
|
158
|
+
offsetY: number;
|
|
159
|
+
ghost: HTMLElement;
|
|
160
|
+
moved: boolean;
|
|
161
|
+
} | null = null;
|
|
162
|
+
private suppressClick = false;
|
|
163
|
+
|
|
164
|
+
private syncItems = () => {
|
|
165
|
+
const items = Array.from(this.querySelectorAll<WebwriterGapItem>("webwriter-gap-item"))
|
|
166
|
+
.filter(item => item.closest("webwriter-gap") === this);
|
|
167
|
+
if (items.length === this.items.length && items.every((item, i) => item === this.items[i])) return;
|
|
168
|
+
|
|
169
|
+
this.cancelDrag();
|
|
170
|
+
for (const [target, choice] of this.placements) {
|
|
171
|
+
if (!items.includes(target) || !items.includes(choice)) this.placements.delete(target);
|
|
172
|
+
}
|
|
173
|
+
if (this.selected && !items.includes(this.selected)) this.selected = null;
|
|
174
|
+
this.items = items;
|
|
175
|
+
this.choices = randomizeArray(items);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** Pushes the state owned by this element onto the items, as part of every update. */
|
|
179
|
+
protected willUpdate() {
|
|
180
|
+
for (const item of this.items) {
|
|
181
|
+
const choice = this.placements.get(item);
|
|
182
|
+
item.mode = this.mode;
|
|
183
|
+
item.graded = this.graded;
|
|
184
|
+
item.answer = choice?.textContent ?? "";
|
|
185
|
+
item.answerSelected = choice !== undefined && choice === this.selected;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Moves `choice` into the gap `target`, or back into the tray of choices for `null`. */
|
|
190
|
+
private place(choice: WebwriterGapItem, target: WebwriterGapItem | null) {
|
|
191
|
+
if (this.graded) return;
|
|
192
|
+
// The gap `choice` currently sits in, or null while it is in the tray.
|
|
193
|
+
const source = Array.from(this.placements).find(([, value]) => value === choice)?.[0] ?? null;
|
|
194
|
+
this.selected = null;
|
|
195
|
+
this.requestUpdate();
|
|
196
|
+
if (source === target) return;
|
|
197
|
+
|
|
198
|
+
// A choice already in `target` swaps places with the incoming one, or returns to the tray.
|
|
199
|
+
const displaced = target ? this.placements.get(target) : undefined;
|
|
200
|
+
if (source) this.placements.delete(source);
|
|
201
|
+
if (source && displaced) this.placements.set(source, displaced);
|
|
202
|
+
if (target) this.placements.set(target, choice);
|
|
203
|
+
/**
|
|
204
|
+
* Dispatched when learners move an answer into or out of a gap. Only in `drag-and-drop` mode.
|
|
205
|
+
*/
|
|
206
|
+
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @internal */
|
|
210
|
+
activateGap(item: WebwriterGapItem) {
|
|
211
|
+
if (this.graded || this.suppressClick || this.isContentEditable || this.mode !== "drag-and-drop") return;
|
|
212
|
+
if (this.selected) this.place(this.selected, item);
|
|
213
|
+
else {
|
|
214
|
+
this.selected = this.placements.get(item) ?? null;
|
|
215
|
+
this.requestUpdate();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @internal */
|
|
220
|
+
startGapDrag(event: PointerEvent, item: WebwriterGapItem) {
|
|
221
|
+
const choice = this.placements.get(item);
|
|
222
|
+
if (choice) this.startDrag(event, choice);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private startDrag(event: PointerEvent, choice: WebwriterGapItem) {
|
|
226
|
+
if (this.graded || event.button !== 0 || !event.isPrimary || this.isContentEditable || this.mode !== "drag-and-drop" || this.drag) return;
|
|
227
|
+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
228
|
+
const ghost = this.ownerDocument.createElement("div");
|
|
229
|
+
ghost.className = "choice ghost";
|
|
230
|
+
ghost.textContent = choice.textContent;
|
|
231
|
+
ghost.style.width = `${rect.width}px`;
|
|
232
|
+
ghost.style.height = `${rect.height}px`;
|
|
233
|
+
this.renderRoot.querySelector("#drag-layer")?.append(ghost);
|
|
234
|
+
this.drag = { choice, pointerId: event.pointerId, x: event.clientX, y: event.clientY,
|
|
235
|
+
offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top, ghost, moved: false };
|
|
236
|
+
window.addEventListener("pointermove", this.moveDrag, { passive: false });
|
|
237
|
+
window.addEventListener("pointerup", this.endDrag);
|
|
238
|
+
window.addEventListener("pointercancel", this.cancelDrag);
|
|
239
|
+
window.addEventListener("blur", this.cancelDrag);
|
|
240
|
+
window.addEventListener("keydown", this.cancelOnEscape);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The gap under the given point, if it belongs to this element. The ghost does not take
|
|
244
|
+
pointer events, so it never hides the gap underneath it. */
|
|
245
|
+
private targetAt(x: number, y: number) {
|
|
246
|
+
const item = this.ownerDocument.elementFromPoint(x, y)?.closest("webwriter-gap-item");
|
|
247
|
+
return item && this.items.includes(item) ? item : undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private overTray(x: number, y: number) {
|
|
251
|
+
return !!(this.renderRoot as ShadowRoot).elementFromPoint(x, y)?.closest(".choices");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private moveDrag = (event: PointerEvent) => {
|
|
255
|
+
const drag = this.drag;
|
|
256
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
257
|
+
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) drag.moved = true;
|
|
258
|
+
if (!drag.moved) return;
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
drag.ghost.style.display = "flex";
|
|
261
|
+
drag.ghost.style.transform = `translate3d(${event.clientX - drag.offsetX}px, ${event.clientY - drag.offsetY}px, 0)`;
|
|
262
|
+
const target = this.targetAt(event.clientX, event.clientY);
|
|
263
|
+
for (const item of this.items) item.dropActive = item === target;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
private endDrag = (event: PointerEvent) => {
|
|
267
|
+
const drag = this.drag;
|
|
268
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
269
|
+
if (drag.moved) {
|
|
270
|
+
this.suppressClick = true;
|
|
271
|
+
setTimeout(() => { this.suppressClick = false; }, 0);
|
|
272
|
+
const target = this.targetAt(event.clientX, event.clientY);
|
|
273
|
+
if (target) this.place(drag.choice, target);
|
|
274
|
+
else if (this.overTray(event.clientX, event.clientY)) this.place(drag.choice, null);
|
|
275
|
+
}
|
|
276
|
+
this.cancelDrag();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
private cancelOnEscape = (event: KeyboardEvent) => {
|
|
280
|
+
if (event.key === "Escape") this.cancelDrag();
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
private cancelDrag = () => {
|
|
284
|
+
this.drag?.ghost.remove();
|
|
285
|
+
this.drag = null;
|
|
286
|
+
for (const item of this.items) item.dropActive = false;
|
|
287
|
+
window.removeEventListener("pointermove", this.moveDrag);
|
|
288
|
+
window.removeEventListener("pointerup", this.endDrag);
|
|
289
|
+
window.removeEventListener("pointercancel", this.cancelDrag);
|
|
290
|
+
window.removeEventListener("blur", this.cancelDrag);
|
|
291
|
+
window.removeEventListener("keydown", this.cancelOnEscape);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
private preventProseMirrorLineBreaks = (event: KeyboardEvent) => {
|
|
295
|
+
// Require collapsed selection (i.e. cursor, not text selection)
|
|
296
|
+
const selection = document.getSelection();
|
|
297
|
+
if (!selection || !selection.isCollapsed) return;
|
|
298
|
+
|
|
299
|
+
// Require cursor inside a gap item of this gap element
|
|
300
|
+
const parent = selection.focusNode?.parentElement;
|
|
301
|
+
if (!parent || parent.tagName !== "WEBWRITER-GAP-ITEM" || parent.closest("webwriter-gap") !== this) return;
|
|
302
|
+
|
|
303
|
+
// Require cursor at the start or end of the gap item
|
|
304
|
+
if (
|
|
305
|
+
(event.key === "ArrowLeft" && selection.focusOffset === 0) ||
|
|
306
|
+
(event.key === "ArrowRight" && selection.focusOffset === selection.focusNode.textContent?.length)
|
|
307
|
+
) {
|
|
308
|
+
// Prevent ProseMirror from inserting a weird line break placeholder/cursor thing
|
|
309
|
+
event.stopPropagation();
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
@state()
|
|
314
|
+
private accessor popupPosition: ({ left: string } & ({ top: string } | { bottom: string })) | null = null;
|
|
315
|
+
|
|
316
|
+
private getGapSelectionAndRange(): [Selection, Range] | [] {
|
|
317
|
+
const selection = document.getSelection();
|
|
318
|
+
if (!selection) return [];
|
|
319
|
+
|
|
320
|
+
// Require non-collapsed selection (i.e. actual text selection, not just a cursor)
|
|
321
|
+
if (selection.rangeCount === 0 || selection.isCollapsed) return [];
|
|
322
|
+
|
|
323
|
+
const range = selection.getRangeAt(0);
|
|
324
|
+
const commonNode =
|
|
325
|
+
range.commonAncestorContainer.nodeType === Node.TEXT_NODE
|
|
326
|
+
? range.commonAncestorContainer.parentElement
|
|
327
|
+
: (range.commonAncestorContainer as HTMLElement);
|
|
328
|
+
if (!commonNode) return [];
|
|
329
|
+
|
|
330
|
+
// Require selection to be inside this gap element
|
|
331
|
+
if (commonNode.closest("webwriter-gap") !== this) return [];
|
|
332
|
+
// Require selection to not be inside an already existing gap item
|
|
333
|
+
if (commonNode.closest("webwriter-gap-item")) return [];
|
|
334
|
+
|
|
335
|
+
return [selection, range];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private insertGapAtSelection() {
|
|
339
|
+
const [selection, range] = this.getGapSelectionAndRange();
|
|
340
|
+
if (!selection || !range) return;
|
|
341
|
+
|
|
342
|
+
const gapItem = document.createElement("webwriter-gap-item");
|
|
343
|
+
let text = range.toString();
|
|
344
|
+
if (text.startsWith(" ")) text = "\u00A0" + text.slice(1);
|
|
345
|
+
gapItem.textContent = text;
|
|
346
|
+
|
|
347
|
+
range.deleteContents();
|
|
348
|
+
range.insertNode(gapItem);
|
|
349
|
+
|
|
350
|
+
selection.removeAllRanges();
|
|
351
|
+
const afterGapRange = document.createRange();
|
|
352
|
+
afterGapRange.setStartAfter(gapItem);
|
|
353
|
+
afterGapRange.collapse(true);
|
|
354
|
+
selection.addRange(afterGapRange);
|
|
355
|
+
this.popupPosition = null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private insertGapOnKeyDown = (event: KeyboardEvent) => {
|
|
359
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toUpperCase() === "G") {
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
this.insertGapAtSelection();
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
connectedCallback(): void {
|
|
366
|
+
super.connectedCallback();
|
|
367
|
+
this.observer.observe(this, { childList: true, subtree: true });
|
|
368
|
+
this.syncItems();
|
|
369
|
+
if (this.isContentEditable) {
|
|
370
|
+
if (isWebWriter()) document.addEventListener("keydown", this.preventProseMirrorLineBreaks, true);
|
|
371
|
+
document.addEventListener("keydown", this.insertGapOnKeyDown);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
disconnectedCallback(): void {
|
|
376
|
+
super.disconnectedCallback();
|
|
377
|
+
this.observer.disconnect();
|
|
378
|
+
this.cancelDrag();
|
|
379
|
+
if (this.isContentEditable) {
|
|
380
|
+
if (isWebWriter()) document.removeEventListener("keydown", this.preventProseMirrorLineBreaks, true);
|
|
381
|
+
document.removeEventListener("keydown", this.insertGapOnKeyDown);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
checkValidity(): boolean {
|
|
386
|
+
this.syncItems();
|
|
387
|
+
return this.items.every(item => this.mode === "input"
|
|
388
|
+
? item.inputAnswer.trim() !== ""
|
|
389
|
+
: this.placements.has(item));
|
|
390
|
+
}
|
|
391
|
+
reset(): void {
|
|
392
|
+
this.cancelDrag();
|
|
393
|
+
this.choices = randomizeArray(this.items);
|
|
394
|
+
this.placements.clear();
|
|
395
|
+
this.selected = null;
|
|
396
|
+
this.graded = false;
|
|
397
|
+
for (const item of this.items) {
|
|
398
|
+
item.inputAnswer = "";
|
|
399
|
+
item.graded = false;
|
|
400
|
+
item.feedback = null;
|
|
401
|
+
}
|
|
402
|
+
this.requestUpdate();
|
|
403
|
+
}
|
|
404
|
+
checkAnswer(detailedFeedback: boolean): number {
|
|
405
|
+
this.syncItems();
|
|
406
|
+
this.cancelDrag();
|
|
407
|
+
this.selected = null;
|
|
408
|
+
this.graded = true;
|
|
409
|
+
let correctCount = 0;
|
|
410
|
+
for (const item of this.items) {
|
|
411
|
+
const choice = this.placements.get(item);
|
|
412
|
+
const answer = (this.mode === "input" ? item.inputAnswer : choice?.textContent ?? "").trim();
|
|
413
|
+
const solution = (item.textContent ?? "").trim();
|
|
414
|
+
const normalize = (value: string) => this.mode === "input" && this.ignoreCase ? value.toLowerCase() : value;
|
|
415
|
+
const correct = solution !== "" && normalize(answer) === normalize(solution);
|
|
416
|
+
if (correct) correctCount++;
|
|
417
|
+
item.graded = true;
|
|
418
|
+
item.feedback = detailedFeedback ? { correct, answer, solution } : null;
|
|
419
|
+
}
|
|
420
|
+
return this.items.length ? correctCount / this.items.length : 1;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
render() {
|
|
424
|
+
const placed = new Set(this.placements.values());
|
|
425
|
+
return html`<div>
|
|
426
|
+
<!-- Holds the drag ghost, which is placed imperatively and must survive re-renders. -->
|
|
427
|
+
<div id="drag-layer"></div>
|
|
428
|
+
<slot style=${styleMap({ "--ww-placeholder": `"${msg("Text")}"` })} @slotchange=${this.syncItems}></slot>
|
|
429
|
+
${this.mode === "drag-and-drop" && !this.isContentEditable ? html`
|
|
430
|
+
<div class="choices" role="group" aria-label=${msg("Available answers")}>
|
|
431
|
+
${this.choices.filter(item => !placed.has(item)).map(item => html`
|
|
432
|
+
<button class="choice" type="button" ?disabled=${this.graded} aria-pressed=${this.selected === item}
|
|
433
|
+
@pointerdown=${(event: PointerEvent) => this.startDrag(event, item)}
|
|
434
|
+
@click=${() => {
|
|
435
|
+
if (this.suppressClick) return;
|
|
436
|
+
this.selected = this.selected === item ? null : item;
|
|
437
|
+
this.requestUpdate();
|
|
438
|
+
}}>${item.textContent}</button>
|
|
439
|
+
`)}
|
|
440
|
+
${this.selected && placed.has(this.selected) ? html`
|
|
441
|
+
<button type="button" @click=${() => this.place(this.selected!, null)}>${msg("Return to answers")}</button>
|
|
442
|
+
` : nothing}
|
|
443
|
+
</div>
|
|
444
|
+
` : nothing}
|
|
445
|
+
</div>`;
|
|
446
|
+
}
|
|
447
|
+
}
|