@vue-skuilder/common-ui 0.1.1
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/dist/assets/index.css +10 -0
- package/dist/common-ui.es.js +16404 -0
- package/dist/common-ui.es.js.map +1 -0
- package/dist/common-ui.umd.js +9 -0
- package/dist/common-ui.umd.js.map +1 -0
- package/dist/components/HeatMap.types.d.ts +13 -0
- package/dist/components/PaginatingToolbar.types.d.ts +40 -0
- package/dist/components/SkMouseTrap.types.d.ts +3 -0
- package/dist/components/SkMouseTrapToolTip.types.d.ts +35 -0
- package/dist/components/SnackbarService.d.ts +11 -0
- package/dist/components/StudySession.types.d.ts +6 -0
- package/dist/components/auth/index.d.ts +4 -0
- package/dist/components/cardRendering/MarkdownRendererHelpers.d.ts +22 -0
- package/dist/components/studentInputs/BaseUserInput.d.ts +16 -0
- package/dist/components/studentInputs/RadioMultipleChoice.types.d.ts +5 -0
- package/dist/composables/CompositionViewable.d.ts +33 -0
- package/dist/composables/Displayable.d.ts +47 -0
- package/dist/composables/index.d.ts +2 -0
- package/dist/index.d.ts +36 -0
- package/dist/plugins/pinia.d.ts +5 -0
- package/dist/stores/useAuthStore.d.ts +225 -0
- package/dist/stores/useCardPreviewModeStore.d.ts +8 -0
- package/dist/stores/useConfigStore.d.ts +11 -0
- package/dist/utils/SkldrMouseTrap.d.ts +32 -0
- package/package.json +67 -0
- package/src/components/HeatMap.types.ts +15 -0
- package/src/components/HeatMap.vue +354 -0
- package/src/components/PaginatingToolbar.types.ts +48 -0
- package/src/components/PaginatingToolbar.vue +75 -0
- package/src/components/SkMouseTrap.types.ts +3 -0
- package/src/components/SkMouseTrap.vue +70 -0
- package/src/components/SkMouseTrapToolTip.types.ts +41 -0
- package/src/components/SkMouseTrapToolTip.vue +316 -0
- package/src/components/SnackbarService.ts +39 -0
- package/src/components/SnackbarService.vue +71 -0
- package/src/components/StudySession.types.ts +6 -0
- package/src/components/StudySession.vue +670 -0
- package/src/components/StudySessionTimer.vue +121 -0
- package/src/components/auth/UserChip.vue +106 -0
- package/src/components/auth/UserLogin.vue +141 -0
- package/src/components/auth/UserLoginAndRegistrationContainer.vue +85 -0
- package/src/components/auth/UserRegistration.vue +181 -0
- package/src/components/auth/index.ts +4 -0
- package/src/components/cardRendering/AudioAutoPlayer.vue +131 -0
- package/src/components/cardRendering/CardLoader.vue +123 -0
- package/src/components/cardRendering/CardViewer.vue +101 -0
- package/src/components/cardRendering/CodeBlockRenderer.vue +81 -0
- package/src/components/cardRendering/MarkdownRenderer.vue +46 -0
- package/src/components/cardRendering/MarkdownRendererHelpers.ts +114 -0
- package/src/components/cardRendering/MdTokenRenderer.vue +244 -0
- package/src/components/studentInputs/BaseUserInput.ts +71 -0
- package/src/components/studentInputs/MultipleChoiceOption.vue +127 -0
- package/src/components/studentInputs/RadioMultipleChoice.types.ts +6 -0
- package/src/components/studentInputs/RadioMultipleChoice.vue +168 -0
- package/src/components/studentInputs/TrueFalse.vue +27 -0
- package/src/components/studentInputs/UserInputNumber.vue +63 -0
- package/src/components/studentInputs/UserInputString.vue +89 -0
- package/src/components/studentInputs/fillInInput.vue +71 -0
- package/src/composables/CompositionViewable.ts +180 -0
- package/src/composables/Displayable.ts +133 -0
- package/src/composables/index.ts +2 -0
- package/src/index.ts +79 -0
- package/src/plugins/pinia.ts +24 -0
- package/src/stores/useAuthStore.ts +92 -0
- package/src/stores/useCardPreviewModeStore.ts +32 -0
- package/src/stores/useConfigStore.ts +60 -0
- package/src/utils/SkldrMouseTrap.ts +141 -0
- package/src/vue-shims.d.ts +5 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span v-if="isText(token)">
|
|
3
|
+
<span v-if="!token.tokens || token.tokens.length === 0">
|
|
4
|
+
<span v-if="isComponent(token)">
|
|
5
|
+
<!-- <component :is="parsedComponent(token).is" v-if="!last" :text="parsedComponent(token).text" /> -->
|
|
6
|
+
<component :is="getComponent(parsedComponent(token).is)" v-if="!last" :text="parsedComponent(token).text" />
|
|
7
|
+
</span>
|
|
8
|
+
<span v-else-if="containsComponent(token)">
|
|
9
|
+
<md-token-renderer v-for="(subTok, j) in splitTextToken(token)" :key="j" :token="subTok" />
|
|
10
|
+
</span>
|
|
11
|
+
<span v-else>{{ decodeBasicEntities(token.text) }}</span>
|
|
12
|
+
</span>
|
|
13
|
+
<span v-else-if="token.tokens && token.tokens.length !== 0">
|
|
14
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
15
|
+
</span>
|
|
16
|
+
</span>
|
|
17
|
+
|
|
18
|
+
<span v-else-if="token.type === 'heading'">
|
|
19
|
+
<h1 v-if="token.depth === 1" class="text-h2">
|
|
20
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
21
|
+
</h1>
|
|
22
|
+
<h2 v-else-if="token.depth === 2" class="text-h3">
|
|
23
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
24
|
+
</h2>
|
|
25
|
+
<h3 v-else-if="token.depth === 3" class="text-h4">
|
|
26
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
27
|
+
</h3>
|
|
28
|
+
<h4 v-else-if="token.depth === 4">
|
|
29
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
30
|
+
</h4>
|
|
31
|
+
<h5 v-else-if="token.depth === 5">
|
|
32
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
33
|
+
</h5>
|
|
34
|
+
<h6 v-else-if="token.depth === 6">
|
|
35
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
36
|
+
</h6>
|
|
37
|
+
</span>
|
|
38
|
+
|
|
39
|
+
<strong v-else-if="token.type === 'strong'">
|
|
40
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
41
|
+
</strong>
|
|
42
|
+
|
|
43
|
+
<p v-else-if="token.type === 'paragraph'" class="text-h5">
|
|
44
|
+
<span v-if="containsComponent(token)">
|
|
45
|
+
<md-token-renderer
|
|
46
|
+
v-for="(splitTok, j) in splitParagraphToken(token)"
|
|
47
|
+
:key="j"
|
|
48
|
+
:token="splitTok"
|
|
49
|
+
:last="last && token.tokens.length === 1 && j === splitParagraphToken(token).length - 1"
|
|
50
|
+
/>
|
|
51
|
+
</span>
|
|
52
|
+
<template v-else>
|
|
53
|
+
<md-token-renderer
|
|
54
|
+
v-for="(subTok, j) in token.tokens"
|
|
55
|
+
:key="j"
|
|
56
|
+
:token="subTok"
|
|
57
|
+
:last="last && token.tokens.length === 1"
|
|
58
|
+
/>
|
|
59
|
+
</template>
|
|
60
|
+
</p>
|
|
61
|
+
|
|
62
|
+
<a v-else-if="token.type === 'link'" :href="token.href" :title="token.title">
|
|
63
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
64
|
+
</a>
|
|
65
|
+
|
|
66
|
+
<ul v-else-if="token.type === 'list' && token.ordered === false">
|
|
67
|
+
<md-token-renderer v-for="(item, j) in token.items" :key="j" :token="item" />
|
|
68
|
+
</ul>
|
|
69
|
+
|
|
70
|
+
<ol v-else-if="token.type === 'list' && token.ordered === true">
|
|
71
|
+
<md-token-renderer v-for="(item, j) in token.items" :key="j" :token="item" />
|
|
72
|
+
</ol>
|
|
73
|
+
|
|
74
|
+
<li v-else-if="token.type === 'list_item'">
|
|
75
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
76
|
+
</li>
|
|
77
|
+
|
|
78
|
+
<img v-else-if="token.type === 'image'" :src="token.href" :alt="token.title" />
|
|
79
|
+
<hr v-else-if="token.type === 'hr'" />
|
|
80
|
+
<br v-else-if="token.type === 'br'" />
|
|
81
|
+
<del v-else-if="token.type === 'del'" />
|
|
82
|
+
|
|
83
|
+
<table v-else-if="token.type === 'table'" :align="token.align">
|
|
84
|
+
<thead>
|
|
85
|
+
<th v-for="(h, j) in token.header" :key="j">{{ h.text }}</th>
|
|
86
|
+
</thead>
|
|
87
|
+
<tbody>
|
|
88
|
+
<tr v-for="(row, r) in token.rows" :key="r">
|
|
89
|
+
<td v-for="(cell, c) in row" :key="c">{{ cell.text }}</td>
|
|
90
|
+
</tr>
|
|
91
|
+
</tbody>
|
|
92
|
+
</table>
|
|
93
|
+
|
|
94
|
+
<span v-else-if="token.type === 'html'" v-html="token.raw"></span>
|
|
95
|
+
|
|
96
|
+
<code-block-renderer v-else-if="token.type === 'code'" :code="token.text" :language="token.lang" />
|
|
97
|
+
<!-- <highlightjs v-else-if="token.type === 'code'" class="hljs_render pa-2" :language="token.lang" :code="token.text" /> -->
|
|
98
|
+
|
|
99
|
+
<code v-else-if="token.type === 'codespan'" class="codespan" v-html="token.text"></code>
|
|
100
|
+
|
|
101
|
+
<blockquote v-else-if="token.type === 'blockquote'">
|
|
102
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
103
|
+
</blockquote>
|
|
104
|
+
|
|
105
|
+
<span v-else-if="token.type === 'escape'">{{ token.text }}</span>
|
|
106
|
+
|
|
107
|
+
<em v-else-if="token.type === 'em'">
|
|
108
|
+
<md-token-renderer v-for="(subTok, j) in token.tokens" :key="j" :token="subTok" />
|
|
109
|
+
</em>
|
|
110
|
+
</template>
|
|
111
|
+
|
|
112
|
+
<script setup lang="ts">
|
|
113
|
+
import {
|
|
114
|
+
containsComponent as _containsComponent,
|
|
115
|
+
isComponent as _isComponent,
|
|
116
|
+
splitParagraphToken as _splitParagraphToken,
|
|
117
|
+
splitTextToken as _splitTextToken,
|
|
118
|
+
TokenOrComponent,
|
|
119
|
+
} from './MarkdownRendererHelpers';
|
|
120
|
+
import CodeBlockRenderer from './CodeBlockRenderer.vue';
|
|
121
|
+
import FillInInput from '../../components/studentInputs/fillInInput.vue';
|
|
122
|
+
import { MarkedToken, Tokens } from 'marked';
|
|
123
|
+
import { markRaw } from 'vue';
|
|
124
|
+
import { PropType } from 'vue';
|
|
125
|
+
|
|
126
|
+
// Register components to be used in the template
|
|
127
|
+
// In Vue 3 with <script setup>, components used via :is must be explicitly registered
|
|
128
|
+
const components = {
|
|
129
|
+
fillIn: markRaw(FillInInput),
|
|
130
|
+
// Add other dynamic components here
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Define component props
|
|
134
|
+
defineProps({
|
|
135
|
+
token: {
|
|
136
|
+
type: Object as PropType<TokenOrComponent>,
|
|
137
|
+
required: true,
|
|
138
|
+
},
|
|
139
|
+
last: {
|
|
140
|
+
type: Boolean,
|
|
141
|
+
required: false,
|
|
142
|
+
default: false,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Methods
|
|
147
|
+
function isComponent(token: MarkedToken): boolean {
|
|
148
|
+
const result = _isComponent(token);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function containsComponent(token: MarkedToken): boolean {
|
|
153
|
+
const result = _containsComponent(token);
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function splitTextToken(token: MarkedToken): Tokens.Text[] {
|
|
158
|
+
return _splitTextToken(token);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function splitParagraphToken(token: Tokens.Paragraph): TokenOrComponent[] {
|
|
162
|
+
return _splitParagraphToken(token);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parsedComponent(token: MarkedToken): {
|
|
166
|
+
is: string;
|
|
167
|
+
text: string;
|
|
168
|
+
} {
|
|
169
|
+
// [ ] switching on component types & loading custom component
|
|
170
|
+
//
|
|
171
|
+
// sketch:
|
|
172
|
+
// const demoustached = token.text.slice(2, token.text.length - 2);
|
|
173
|
+
// const firstToken = demoustached.split(' ')[0];
|
|
174
|
+
// if (firstToken.charAt(firstToken.length - 1) == '>') {
|
|
175
|
+
// return {
|
|
176
|
+
// is: firstToken.slice(0, firstToken.length - 1),
|
|
177
|
+
// text: demoustached.slice(firstToken.length + 1, demoustached.length),
|
|
178
|
+
// };
|
|
179
|
+
// }
|
|
180
|
+
|
|
181
|
+
let text = '';
|
|
182
|
+
if ('text' in token && typeof token.text === 'string') {
|
|
183
|
+
text = token.text;
|
|
184
|
+
} else if ('raw' in token && typeof token.raw === 'string') {
|
|
185
|
+
text = token.raw;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// This now returns a component from our registered components object
|
|
189
|
+
return {
|
|
190
|
+
is: 'fillIn', // This should match a key in the components object
|
|
191
|
+
text,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getComponent(componentName: string) {
|
|
196
|
+
// Return the component instance from our components object
|
|
197
|
+
return components[componentName as keyof typeof components];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function decodeBasicEntities(text: string): string {
|
|
201
|
+
return text
|
|
202
|
+
.replace(/'/g, "'")
|
|
203
|
+
.replace(/"/g, '"')
|
|
204
|
+
.replace(/&/g, '&')
|
|
205
|
+
.replace(/</g, '<')
|
|
206
|
+
.replace(/>/g, '>');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isText(tok: TokenOrComponent): boolean {
|
|
210
|
+
return (tok as any).inLink === undefined && tok.type === 'text';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Make these functions and objects available to the template
|
|
214
|
+
defineExpose({
|
|
215
|
+
isComponent,
|
|
216
|
+
containsComponent,
|
|
217
|
+
splitTextToken,
|
|
218
|
+
splitParagraphToken,
|
|
219
|
+
parsedComponent,
|
|
220
|
+
decodeBasicEntities,
|
|
221
|
+
isText,
|
|
222
|
+
components,
|
|
223
|
+
getComponent,
|
|
224
|
+
});
|
|
225
|
+
</script>
|
|
226
|
+
|
|
227
|
+
<style lang="css" scoped>
|
|
228
|
+
blockquote {
|
|
229
|
+
border-left: 3px teal solid;
|
|
230
|
+
padding-left: 8px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.codespan {
|
|
234
|
+
padding-left: 3px;
|
|
235
|
+
padding-right: 3px;
|
|
236
|
+
margin-left: 1px;
|
|
237
|
+
margin-right: 1px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
p {
|
|
241
|
+
margin-bottom: 15px;
|
|
242
|
+
margin-top: 15px;
|
|
243
|
+
}
|
|
244
|
+
</style>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { defineComponent } from 'vue';
|
|
2
|
+
import { Answer, log } from '@vue-skuilder/common';
|
|
3
|
+
import { useCardPreviewModeStore } from '../../stores/useCardPreviewModeStore';
|
|
4
|
+
import { ViewComponent } from '../../composables/Displayable';
|
|
5
|
+
import { isQuestionView } from '../../composables/CompositionViewable';
|
|
6
|
+
import { QuestionRecord } from '@vue-skuilder/db';
|
|
7
|
+
import { Store } from 'pinia';
|
|
8
|
+
|
|
9
|
+
export default defineComponent({
|
|
10
|
+
name: 'UserInput',
|
|
11
|
+
data() {
|
|
12
|
+
return {
|
|
13
|
+
answer: '' as Answer,
|
|
14
|
+
previewModeStore: null as ReturnType<typeof useCardPreviewModeStore> | null,
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
mounted() {
|
|
18
|
+
// This happens after Pinia is initialized but before the component is used
|
|
19
|
+
this.previewModeStore = useCardPreviewModeStore();
|
|
20
|
+
},
|
|
21
|
+
computed: {
|
|
22
|
+
autofocus(): boolean {
|
|
23
|
+
return !this.previewModeStore?.previewMode;
|
|
24
|
+
},
|
|
25
|
+
autoFocus(): boolean {
|
|
26
|
+
return this.autofocus;
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
methods: {
|
|
30
|
+
submitAnswer(answer: Answer): QuestionRecord {
|
|
31
|
+
return this.submit(answer);
|
|
32
|
+
},
|
|
33
|
+
// isQuestionView(a: any): a is QuestionView<Question> {
|
|
34
|
+
// return (a as QuestionView<Question>).submitAnswer !== undefined;
|
|
35
|
+
// },
|
|
36
|
+
submit(answer: Answer) {
|
|
37
|
+
return this.getQuestionViewAncestor().submitAnswer(answer, this.$options.name ?? 'UserInput');
|
|
38
|
+
},
|
|
39
|
+
getQuestionViewAncestor(): ViewComponent {
|
|
40
|
+
let ancestor = this.$parent;
|
|
41
|
+
let count = 0;
|
|
42
|
+
|
|
43
|
+
while (ancestor && !isQuestionView(ancestor)) {
|
|
44
|
+
const nextAncestor = ancestor.$parent;
|
|
45
|
+
if (!nextAncestor) {
|
|
46
|
+
const err = `
|
|
47
|
+
UserInput.submit() has failed.
|
|
48
|
+
The input element has no QuestionView ancestor element.`;
|
|
49
|
+
log(err);
|
|
50
|
+
throw new Error(err);
|
|
51
|
+
}
|
|
52
|
+
ancestor = nextAncestor;
|
|
53
|
+
count++;
|
|
54
|
+
|
|
55
|
+
if (count > 100) {
|
|
56
|
+
const err = `
|
|
57
|
+
UserInput.submit() has failed.
|
|
58
|
+
Exceeded maximum ancestor lookup depth.`;
|
|
59
|
+
log(err);
|
|
60
|
+
throw new Error(err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!ancestor) {
|
|
65
|
+
throw new Error('No QuestionView ancestor found');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return ancestor;
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card :class="`${className}`" @mouseover="select" @click="submitThisOption">
|
|
3
|
+
<markdown-renderer :md="content" />
|
|
4
|
+
</v-card>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { defineComponent, defineAsyncComponent, PropType } from 'vue';
|
|
9
|
+
|
|
10
|
+
export default defineComponent({
|
|
11
|
+
name: 'MultipleChoiceOption',
|
|
12
|
+
|
|
13
|
+
components: {
|
|
14
|
+
MarkdownRenderer: defineAsyncComponent(() => import('../../components/cardRendering/MarkdownRenderer.vue')),
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
props: {
|
|
18
|
+
content: {
|
|
19
|
+
type: String,
|
|
20
|
+
required: true,
|
|
21
|
+
},
|
|
22
|
+
selected: {
|
|
23
|
+
type: Boolean,
|
|
24
|
+
required: true,
|
|
25
|
+
},
|
|
26
|
+
number: {
|
|
27
|
+
type: Number,
|
|
28
|
+
required: true,
|
|
29
|
+
},
|
|
30
|
+
setSelection: {
|
|
31
|
+
type: Function as PropType<(selection: number) => void>,
|
|
32
|
+
required: true,
|
|
33
|
+
},
|
|
34
|
+
submit: {
|
|
35
|
+
type: Function as PropType<() => void>,
|
|
36
|
+
required: true,
|
|
37
|
+
},
|
|
38
|
+
markedWrong: {
|
|
39
|
+
type: Boolean,
|
|
40
|
+
required: true,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
computed: {
|
|
45
|
+
className(): string {
|
|
46
|
+
let color: string;
|
|
47
|
+
|
|
48
|
+
switch (this.number) {
|
|
49
|
+
case 0:
|
|
50
|
+
color = 'bg-red';
|
|
51
|
+
break;
|
|
52
|
+
case 1:
|
|
53
|
+
color = 'bg-purple';
|
|
54
|
+
break;
|
|
55
|
+
case 2:
|
|
56
|
+
color = 'bg-indigo';
|
|
57
|
+
break;
|
|
58
|
+
case 3:
|
|
59
|
+
color = 'bg-light-blue';
|
|
60
|
+
break;
|
|
61
|
+
case 4:
|
|
62
|
+
color = 'bg-teal';
|
|
63
|
+
break;
|
|
64
|
+
case 5:
|
|
65
|
+
color = 'bg-deep-orange';
|
|
66
|
+
break;
|
|
67
|
+
default:
|
|
68
|
+
color = 'bg-grey';
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (this.selected && !this.markedWrong) {
|
|
73
|
+
return `choice selected ${color} lighten-3 elevation-8`;
|
|
74
|
+
} else if (!this.selected && !this.markedWrong) {
|
|
75
|
+
return `choice not-selected ${color} lighten-4 elevation-1`;
|
|
76
|
+
} else if (this.selected && this.markedWrong) {
|
|
77
|
+
return `choice selected grey lighten-2 elevation-8`;
|
|
78
|
+
} else if (!this.selected && this.markedWrong) {
|
|
79
|
+
return 'choice not-selected grey lighten-2 elevation-0';
|
|
80
|
+
} else {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`'selected' and 'markedWrong' props in MultipleChoiceOption are in an impossible configuration.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
methods: {
|
|
89
|
+
select(): void {
|
|
90
|
+
this.setSelection(this.number);
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
submitThisOption(): void {
|
|
94
|
+
if (this.markedWrong) {
|
|
95
|
+
return;
|
|
96
|
+
} else {
|
|
97
|
+
this.select();
|
|
98
|
+
this.submit();
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<style scoped>
|
|
106
|
+
.choice {
|
|
107
|
+
text-align: center;
|
|
108
|
+
display: inline-block;
|
|
109
|
+
border-radius: 4px;
|
|
110
|
+
padding-left: 15px;
|
|
111
|
+
padding-right: 15px;
|
|
112
|
+
padding-top: 5px;
|
|
113
|
+
padding-bottom: 5px;
|
|
114
|
+
margin: 10px;
|
|
115
|
+
min-width: 75px; /* prevent tiny click-btns on, eg, one-letter answers */
|
|
116
|
+
transition: all 0.2s ease-in-out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.selected {
|
|
120
|
+
transform: translateY(-10px) /* rotate(3deg) */ scale(1.15);
|
|
121
|
+
z-index: 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.not-selected {
|
|
125
|
+
z-index: 0;
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="containerRef" class="multipleChoice">
|
|
3
|
+
<MultipleChoiceOption
|
|
4
|
+
v-for="(choice, i) in choiceList"
|
|
5
|
+
:key="i"
|
|
6
|
+
:content="choice"
|
|
7
|
+
:selected="choiceList.indexOf(choice) === currentSelection"
|
|
8
|
+
:number="choiceList.indexOf(choice)"
|
|
9
|
+
:set-selection="setSelection"
|
|
10
|
+
:submit="forwardSelection"
|
|
11
|
+
:marked-wrong="choiceIsWrong(choice)"
|
|
12
|
+
/>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script lang="ts">
|
|
17
|
+
import UserInput from './BaseUserInput';
|
|
18
|
+
import MultipleChoiceOption from './MultipleChoiceOption.vue';
|
|
19
|
+
import { defineComponent, PropType } from 'vue';
|
|
20
|
+
import { SkldrMouseTrap } from '../../utils/SkldrMouseTrap';
|
|
21
|
+
import { RadioMultipleChoiceAnswer } from './RadioMultipleChoice.types';
|
|
22
|
+
|
|
23
|
+
export default defineComponent({
|
|
24
|
+
name: 'RadioMultipleChoice',
|
|
25
|
+
components: {
|
|
26
|
+
MultipleChoiceOption,
|
|
27
|
+
},
|
|
28
|
+
extends: UserInput,
|
|
29
|
+
props: {
|
|
30
|
+
choiceList: {
|
|
31
|
+
type: Array as PropType<string[]>,
|
|
32
|
+
required: true,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
data() {
|
|
36
|
+
return {
|
|
37
|
+
currentSelection: -1,
|
|
38
|
+
incorrectSelections: [] as number[],
|
|
39
|
+
containerRef: null as null | HTMLElement,
|
|
40
|
+
_registeredHotkeys: [] as (string | string[])[],
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
watch: {
|
|
44
|
+
choiceList: {
|
|
45
|
+
immediate: true,
|
|
46
|
+
handler(newList) {
|
|
47
|
+
if (newList?.length) {
|
|
48
|
+
this.bindKeys();
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
mounted() {
|
|
54
|
+
if (this.containerRef) {
|
|
55
|
+
this.containerRef.focus();
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
unmounted() {
|
|
59
|
+
this.unbindKeys();
|
|
60
|
+
},
|
|
61
|
+
methods: {
|
|
62
|
+
forwardSelection(): void {
|
|
63
|
+
if (this.choiceIsWrong(this.choiceList[this.currentSelection])) {
|
|
64
|
+
return;
|
|
65
|
+
} else if (this.currentSelection !== -1) {
|
|
66
|
+
const ans: RadioMultipleChoiceAnswer = {
|
|
67
|
+
choiceList: this.choiceList,
|
|
68
|
+
selection: this.currentSelection,
|
|
69
|
+
};
|
|
70
|
+
const record = this.submitAnswer(ans);
|
|
71
|
+
|
|
72
|
+
if (!record.isCorrect) {
|
|
73
|
+
this.incorrectSelections.push(this.currentSelection);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
setSelection(selection: number): void {
|
|
78
|
+
if (selection < this.choiceList.length) {
|
|
79
|
+
this.currentSelection = selection;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
incrementSelection(): void {
|
|
83
|
+
if (this.currentSelection === -1) {
|
|
84
|
+
this.currentSelection = Math.ceil(this.choiceList.length / 2);
|
|
85
|
+
} else {
|
|
86
|
+
this.currentSelection = Math.min(this.choiceList.length - 1, this.currentSelection + 1);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
decrementSelection(): void {
|
|
90
|
+
if (this.currentSelection === -1) {
|
|
91
|
+
this.currentSelection = Math.floor(this.choiceList.length / 2 - 1);
|
|
92
|
+
} else {
|
|
93
|
+
this.currentSelection = Math.max(0, this.currentSelection - 1);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
choiceIsWrong(choice: string): boolean {
|
|
97
|
+
let ret = false;
|
|
98
|
+
this.incorrectSelections.forEach((sel) => {
|
|
99
|
+
if (this.choiceList[sel] === choice) {
|
|
100
|
+
ret = true;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return ret;
|
|
104
|
+
},
|
|
105
|
+
bindKeys() {
|
|
106
|
+
const hotkeys = [
|
|
107
|
+
{
|
|
108
|
+
hotkey: 'left',
|
|
109
|
+
callback: this.decrementSelection,
|
|
110
|
+
command: 'Move selection left',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
hotkey: 'right',
|
|
114
|
+
callback: this.incrementSelection,
|
|
115
|
+
command: 'Move selection right',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
hotkey: 'enter',
|
|
119
|
+
callback: this.forwardSelection,
|
|
120
|
+
command: 'Submit selection',
|
|
121
|
+
},
|
|
122
|
+
// Add number key bindings
|
|
123
|
+
...Array.from({ length: this.choiceList.length }, (_, i) => ({
|
|
124
|
+
hotkey: (i + 1).toString(),
|
|
125
|
+
callback: () => this.setSelection(i),
|
|
126
|
+
command: `Select ${((i) => {
|
|
127
|
+
switch (i) {
|
|
128
|
+
case 0:
|
|
129
|
+
return 'first';
|
|
130
|
+
case 1:
|
|
131
|
+
return 'second';
|
|
132
|
+
case 2:
|
|
133
|
+
return 'third';
|
|
134
|
+
case 3:
|
|
135
|
+
return 'fourth';
|
|
136
|
+
case 4:
|
|
137
|
+
return 'fifth';
|
|
138
|
+
case 5:
|
|
139
|
+
return 'sixth';
|
|
140
|
+
default:
|
|
141
|
+
return `${i + 1}th`;
|
|
142
|
+
}
|
|
143
|
+
})(i)} option`,
|
|
144
|
+
})),
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
SkldrMouseTrap.addBinding(hotkeys);
|
|
148
|
+
|
|
149
|
+
// Store hotkeys for cleanup
|
|
150
|
+
this._registeredHotkeys = hotkeys.map(k => k.hotkey);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
unbindKeys() {
|
|
154
|
+
// Remove specific hotkeys instead of resetting all
|
|
155
|
+
if (this._registeredHotkeys) {
|
|
156
|
+
this._registeredHotkeys.forEach(key => {
|
|
157
|
+
SkldrMouseTrap.removeBinding(key);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
// bindNumberKey(n: number): void {
|
|
162
|
+
// this.MouseTrap.bind(n.toString(), () => {
|
|
163
|
+
// this.currentSelection = n - 1;
|
|
164
|
+
// });
|
|
165
|
+
// },
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
</script>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div data-viewable="TrueFalse">
|
|
3
|
+
<RadioMultipleChoice :choice-list="['True', 'False']" :MouseTrap="MouseTrap" :submit="submit" />
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { defineComponent, PropType } from 'vue';
|
|
9
|
+
import RadioMultipleChoice from './RadioMultipleChoice.vue';
|
|
10
|
+
|
|
11
|
+
export default defineComponent({
|
|
12
|
+
name: 'TrueFalse',
|
|
13
|
+
components: {
|
|
14
|
+
RadioMultipleChoice,
|
|
15
|
+
},
|
|
16
|
+
props: {
|
|
17
|
+
MouseTrap: {
|
|
18
|
+
type: Object as PropType<any>,
|
|
19
|
+
required: true,
|
|
20
|
+
},
|
|
21
|
+
submit: {
|
|
22
|
+
type: Function as PropType<(selection: number) => void>,
|
|
23
|
+
required: true,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
</script>
|