Add exercise type components
Add functioning exercise components for support. Includes test exercises to make sure they work. ComposeDecompose: Takes a value and shows exercise with 2 inputs and hints automaticly. Can change the ammount of options it has. Connectlines: Exercise to connect between eachother. Has two modes, sum and picture, sum uses a 1+1 style of options, and picture uses the grid layout for more complexity. Picture in the middle is currently default blocks, this has to be conversed of how it should be displayed. PictureChoose.vue: This one shows grid with the first and second value, the correct answer must have a boolean value stating this is the correct one. Substractvisual.vue: This one is the most loose, as it only uses the grids as visuals and answer purposes. Not more than that. Wheremore.vue: Has two modes, less or more (if non it defaults to more), Takes a left value and a right value to create the execise. Examples of the structure are detailed in each component. And app.vue has demo exercises for testing and visual purposes.
This commit is contained in:
@@ -59,8 +59,10 @@
|
||||
<div v-for="(ex, i) in draft" :key="i" class="draft-item"
|
||||
:class="{ 'draft-item--pinned': pinnedPreview === ex }"
|
||||
@click="pinnedPreview = pinnedPreview === ex ? null : ex">
|
||||
<span class="draft-nums">{{ ex.grids.filter(g => g.mode !== 'target').map(g => g.label || g.value).join(' + ') }}</span>
|
||||
<span class="draft-dropzones">→ {{ ex.grids.filter(g => g.mode === 'target').map(g => g.label).join(', ') || 'sin banca' }}</span>
|
||||
<span class="draft-nums">{{ex.grids.filter(g => g.mode !== 'target').map(g => g.label ||
|
||||
g.value).join(' + ')}}</span>
|
||||
<span class="draft-dropzones">→ {{ex.grids.filter(g => g.mode === 'target').map(g => g.label).join(',')
|
||||
|| 'sin banca' }}</span>
|
||||
<button class="btn-icon btn-delete"
|
||||
@click.stop="draft.splice(i, 1); if (pinnedPreview === ex) pinnedPreview = null">✕</button>
|
||||
</div>
|
||||
@@ -79,23 +81,29 @@
|
||||
@click="form.exerciseType = 'input-check'">Escribir número</button>
|
||||
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'sides' }"
|
||||
@click="form.exerciseType = 'sides'">Lados ↔</button>
|
||||
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'mark-options' }"
|
||||
@click="form.exerciseType = 'mark-options'">Marcar ✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout -->
|
||||
<div class="field" v-if="form.exerciseType !== 'sides'">
|
||||
<div class="field" v-if="form.exerciseType !== 'sides' && form.exerciseType !== 'mark-options'">
|
||||
<label>Disposición</label>
|
||||
<div class="mode-toggle">
|
||||
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row' }"
|
||||
@click="form.layout = 'row'">Fila →</button>
|
||||
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'column' }"
|
||||
@click="form.layout = 'column'">Columna ↓</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row-answer-right' }"
|
||||
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'row' }" @click="form.layout = 'row'">Fila →</button>
|
||||
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'column' }" @click="form.layout = 'column'">Columna
|
||||
↓</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'row-answer-right' }"
|
||||
@click="form.layout = 'row-answer-right'">Resp. →</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row-answer-bottom' }"
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'row-answer-bottom' }"
|
||||
@click="form.layout = 'row-answer-bottom'">Resp. ↓</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'column' }"
|
||||
@click="form.layout = 'column'">Columna ↓</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'column' }" @click="form.layout = 'column'">Columna
|
||||
↓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,8 +146,8 @@
|
||||
v-model="g.label" />
|
||||
</div>
|
||||
<!-- Fruit picker — dropdown select -->
|
||||
<div v-if="g.value > 0" class="fruit-select-wrap">
|
||||
<select class="fruit-select" v-model="g.fruit">
|
||||
<div v-if="g.value > 0" class="image-select-wrap">
|
||||
<select class="image-select" v-model="g.image">
|
||||
<option :value="null">🟥 Bloques de color</option>
|
||||
<option v-for="f in FRUITS" :key="f.key" :value="f.key">
|
||||
{{ f.emoji }} {{ f.label }}
|
||||
@@ -150,8 +158,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mark-options: which grid is correct -->
|
||||
<div class="field" v-if="form.exerciseType === 'mark-options' && validGrids.length >= 2">
|
||||
<label>Respuesta correcta</label>
|
||||
<select class="image-select" v-model="form.correctGridIdx" style="max-width:220px">
|
||||
<option v-for="(g, i) in validGrids" :key="i" :value="i">
|
||||
{{ g.label || ('Grid ' + (form.grids.indexOf(g) + 1)) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- move-blocks / sides: banca config -->
|
||||
<div class="field" v-if="(form.exerciseType === 'move-blocks' || form.exerciseType === 'sides') && validGrids.length">
|
||||
<div class="field"
|
||||
v-if="(form.exerciseType === 'move-blocks' || form.exerciseType === 'sides') && validGrids.length">
|
||||
<label>Banca <span class="hint">— respuesta esperada por origen</span></label>
|
||||
<div class="grid-row" style="margin-bottom:0.5rem">
|
||||
<input class="grid-label-input" type="text" placeholder="Etiqueta respuesta" v-model="form.bancaLabel"
|
||||
@@ -214,21 +233,20 @@ import Blocks from './components/Blocks.vue';
|
||||
const screen = ref(null);
|
||||
|
||||
// ── FRUIT CATALOGUE ──
|
||||
// Keys must match what Blocks.vue / FRUIT_DICT accepts
|
||||
// Keys must match FRUIT_DICT in Blocks.vue exactly
|
||||
// Keep in sync with IMAGE_DICT in Blocks.vue manually when adding/removing images.
|
||||
const FRUITS = [
|
||||
{ key: 'manzana', emoji: '🍎', label: 'Manzana' },
|
||||
{ key: 'banana', emoji: '🍌', label: 'Banana' },
|
||||
{ key: 'naranja', emoji: '🍊', label: 'Naranja' },
|
||||
{ key: 'frutilla', emoji: '🍓', label: 'Frutilla' },
|
||||
{ key: 'uva', emoji: '🍇', label: 'Uva' },
|
||||
{ key: 'limon', emoji: '🍋', label: 'Limón' },
|
||||
{ key: 'zanahoria', emoji: '🥕', label: 'Zanahoria' },
|
||||
{ key: 'mango', emoji: '🥭', label: 'Mango' },
|
||||
{ key: 'tomate', emoji: '🍅', label: 'Tomate' },
|
||||
{ key: 'palta', emoji: '🥑', label: 'Palta' },
|
||||
{ key: 'kiwi', emoji: '🥝', label: 'Kiwi' },
|
||||
{ key: 'pina', emoji: '🍍', label: 'Piña' },
|
||||
{ key: 'manzana', emoji: '🍎', label: 'Manzana' },
|
||||
{ key: 'banana', emoji: '🍌', label: 'Banana' },
|
||||
{ key: 'frutilla', emoji: '🍓', label: 'Frutilla' },
|
||||
{ key: 'zanahoria', emoji: '🥕', label: 'Zanahoria' },
|
||||
{ key: 'limon', emoji: '🍋', label: 'Limón' },
|
||||
{ key: 'mango', emoji: '🥭', label: 'Mango' },
|
||||
{ key: 'uva', emoji: '🍇', label: 'Uva' },
|
||||
{ key: 'tomate', emoji: '🍅', label: 'Tomate' },
|
||||
{ key: 'palta', emoji: '🥑', label: 'Palta' },
|
||||
{ key: 'kiwi', emoji: '🥝', label: 'Kiwi' },
|
||||
{ key: 'naranja', emoji: '🍊', label: 'Naranja' },
|
||||
{ key: 'pina', emoji: '🍍', label: 'Piña' },
|
||||
];
|
||||
|
||||
// ── DEMO EXERCISES ──
|
||||
@@ -242,73 +260,224 @@ const FRUITS = [
|
||||
// answerSlot: { expects: number } (for input-check at exercise level)
|
||||
|
||||
const demoExercises = ref([
|
||||
// ── move-blocks: operandos activos, banca target a la derecha ──
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// NEW EXERCISE COMPONENTS
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
// ── compose-decompose ──
|
||||
{
|
||||
exerciseType: 'move-blocks',
|
||||
layout: 'row-answer-right',
|
||||
sidesAnswerLabel: '',
|
||||
text: "María tiene 2 manzanas y Juan tiene 3. ¿Cuántas tienen en total?",
|
||||
grids: [
|
||||
{ id: 'maria', mode: 'active', value: 2, label: 'María', fruit: 'manzana' },
|
||||
{ id: 'juan', mode: 'active', value: 3, label: 'Juan', fruit: 'zanahoria' },
|
||||
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 5 } },
|
||||
exerciseType: 'compose-decompose',
|
||||
text: '¿Qué números forman el 10?',
|
||||
whole: 10, numberOfOptions: 4, image: 'manzana',
|
||||
},
|
||||
{
|
||||
exerciseType: 'compose-decompose',
|
||||
text: '¿Qué números forman el 7?',
|
||||
whole: 7, numberOfOptions: 3, image: 'naranja',
|
||||
},
|
||||
|
||||
// ── picture-choose ──
|
||||
{
|
||||
exerciseType: 'picture-choose',
|
||||
text: 'Hay 5 manzanas y 4 naranjas. ¿Cuántas hay en total?',
|
||||
firstImage: 'manzana', secondImage: 'naranja',
|
||||
options: [
|
||||
{ first: 3, second: 4, correct: false },
|
||||
{ first: 5, second: 2, correct: false },
|
||||
{ first: 5, second: 4, correct: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
exerciseType: 'picture-choose',
|
||||
text: 'Hay 6 fresas y 3 limones. ¿Cuántas hay en total?',
|
||||
firstImage: 'frutilla', secondImage: 'limon',
|
||||
options: [
|
||||
{ first: 4, second: 3, correct: false },
|
||||
{ first: 6, second: 3, correct: true },
|
||||
{ first: 6, second: 5, correct: false },
|
||||
{ first: 5, second: 4, correct: false },
|
||||
],
|
||||
},
|
||||
|
||||
// ── move-blocks: por origen específico, banca abajo ──
|
||||
// ── where-more ──
|
||||
{
|
||||
exerciseType: 'where-more',
|
||||
text: '¿Dónde hay más? Marca con una X.',
|
||||
mode: 'more',
|
||||
comparisons: [
|
||||
{ left: { value: 6, image: 'manzana' }, right: { value: 5, image: 'banana' } },
|
||||
{ left: { value: 4, image: 'naranja' }, right: { value: 7, image: 'frutilla' } },
|
||||
{ left: { value: 8, image: 'uva' }, right: { value: 3, image: 'limon' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
exerciseType: 'where-more',
|
||||
text: '¿Dónde hay menos? Marca con una X.',
|
||||
mode: 'less',
|
||||
comparisons: [
|
||||
{ left: { value: 9, image: 'manzana' }, right: { value: 2, image: 'naranja' } },
|
||||
{ left: { value: 3, image: 'banana' }, right: { value: 8, image: 'kiwi' } },
|
||||
],
|
||||
},
|
||||
|
||||
// ── connect: labels ──
|
||||
{
|
||||
exerciseType: 'connect',
|
||||
text: 'Conecta las sumas que tienen el mismo resultado.',
|
||||
columns: 2,
|
||||
leftItems: [
|
||||
{ id: 'l1', label: '3 + 5' },
|
||||
{ id: 'l2', label: '2 + 4' },
|
||||
{ id: 'l3', label: '4 + 5' },
|
||||
],
|
||||
rightItems: [
|
||||
{ id: 'r1', label: '6 + 3' },
|
||||
{ id: 'r2', label: '4 + 4' },
|
||||
{ id: 'r3', label: '1 + 5' },
|
||||
],
|
||||
correctPairs: [
|
||||
{ left: 'l1', right: 'r2' },
|
||||
{ left: 'l2', right: 'r3' },
|
||||
{ left: 'l3', right: 'r1' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── connect: grids 2 columns ──
|
||||
{
|
||||
exerciseType: 'connect',
|
||||
text: 'Conecta los que tienen la misma cantidad.',
|
||||
columns: 2,
|
||||
leftItems: [
|
||||
{ id: 'l1', grids: [{ value: 3, image: 'manzana' }, { value: 5, image: 'manzana' }] },
|
||||
{ id: 'l2', grids: [{ value: 2, image: 'naranja' }, { value: 4, image: 'naranja' }] },
|
||||
{ id: 'l3', grids: [{ value: 4, image: 'banana' }, { value: 5, image: 'banana' }] },
|
||||
],
|
||||
rightItems: [
|
||||
{ id: 'r1', grids: [{ value: 4, image: 'frutilla' }, { value: 4, image: 'frutilla' }] },
|
||||
{ id: 'r2', grids: [{ value: 6, image: 'zanahoria' }, { value: 3, image: 'zanahoria' }] },
|
||||
{ id: 'r3', grids: [{ value: 5, image: 'limon' }, { value: 1, image: 'limon' }] },
|
||||
],
|
||||
correctPairs: [
|
||||
{ left: 'l1', right: 'r1' },
|
||||
{ left: 'l2', right: 'r3' },
|
||||
{ left: 'l3', right: 'r2' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── connect: 3 columns ──
|
||||
{
|
||||
exerciseType: 'connect',
|
||||
text: 'Conecta cada grupo con su número.',
|
||||
columns: 3,
|
||||
leftItems: [
|
||||
{ id: 'l1', grids: [{ value: 4, image: 'naranja' }] },
|
||||
{ id: 'l2', grids: [{ value: 2, image: 'manzana' }] },
|
||||
{ id: 'l3', grids: [{ value: 3, image: 'banana' }] },
|
||||
],
|
||||
centerItems: [
|
||||
{ id: 'c1', grids: [{ value: 2, color: '#6366f1' }] },
|
||||
{ id: 'c2', grids: [{ value: 3, color: '#6366f1' }] },
|
||||
{ id: 'c3', grids: [{ value: 4, color: '#6366f1' }] },
|
||||
],
|
||||
rightItems: [
|
||||
{ id: 'r1', grids: [{ value: 3, image: 'limon' }] },
|
||||
{ id: 'r2', grids: [{ value: 4, image: 'zanahoria' }] },
|
||||
{ id: 'r3', grids: [{ value: 2, image: 'frutilla' }] },
|
||||
],
|
||||
correctPairs: [
|
||||
{ left: 'l1', center: 'c3' },
|
||||
{ left: 'l2', center: 'c1' },
|
||||
{ left: 'l3', center: 'c2' },
|
||||
{ right: 'r1', center: 'c2' },
|
||||
{ right: 'r2', center: 'c3' },
|
||||
{ right: 'r3', center: 'c1' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── subtract-visual ──
|
||||
{
|
||||
exerciseType: 'subtract-visual',
|
||||
text: 'Hay 5 manzanas. Se comieron 2. ¿Cuántas quedan?',
|
||||
initialQuantity: 5, removedQuantity: 2, image: 'manzana',
|
||||
options: [{ id: 'o1', value: 3 }, { id: 'o2', value: 4 }, { id: 'o3', value: 2 }, { id: 'o4', value: 1 }],
|
||||
correctAnswer: 3,
|
||||
},
|
||||
{
|
||||
exerciseType: 'subtract-visual',
|
||||
text: 'Hay 9 naranjas. Se sacaron 4. ¿Cuántas quedan?',
|
||||
initialQuantity: 9, removedQuantity: 4, image: 'naranja',
|
||||
options: [{ id: 'o1', value: 5 }, { id: 'o2', value: 6 }, { id: 'o3', value: 3 }, { id: 'o4', value: 4 }],
|
||||
correctAnswer: 5,
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// LEGACY / BLOCKS-BASED EXERCISES
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
// ── move-blocks: answer right ──
|
||||
{
|
||||
exerciseType: 'move-blocks',
|
||||
layout: 'row-answer-right',
|
||||
text: "María tiene 2 manzanas y Juan tiene 3. ¿Cuántas tienen en total?",
|
||||
grids: [
|
||||
{ id: 'maria', mode: 'active', value: 2, label: 'María', image: 'manzana' },
|
||||
{ id: 'juan', mode: 'active', value: 3, label: 'Juan', image: 'zanahoria' },
|
||||
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 5 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ── move-blocks: answer bottom, by origin ──
|
||||
{
|
||||
exerciseType: 'move-blocks',
|
||||
layout: 'row-answer-bottom',
|
||||
text: "Pedro tiene 3 manzanas y Alicia tiene 2. Pedro le da 2 a Alicia. ¿Cuántas tiene Alicia?",
|
||||
grids: [
|
||||
{ id: 'pedro', mode: 'active', value: 3, label: 'Pedro', fruit: 'manzana' },
|
||||
{ id: 'alicia', mode: 'active', value: 2, label: 'Alicia', fruit: 'frutilla' },
|
||||
{ id: 'resp', mode: 'target', label: 'Alicia al final',
|
||||
answerSlot: { from: [{ id: 'pedro', count: 2 }, { id: 'alicia', count: 2 }] } },
|
||||
{ id: 'pedro', mode: 'active', value: 3, label: 'Pedro', image: 'manzana' },
|
||||
{ id: 'alicia', mode: 'active', value: 2, label: 'Alicia', image: 'frutilla' },
|
||||
{
|
||||
id: 'resp', mode: 'target', label: 'Alicia al final',
|
||||
answerSlot: { from: [{ id: 'pedro', count: 2 }, { id: 'alicia', count: 2 }] }
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── input-check: grids estáticos, casilla de número a la derecha ──
|
||||
{
|
||||
exerciseType: 'input-check',
|
||||
layout: 'row',
|
||||
answerPosition: 'right',
|
||||
hideBorders: false,
|
||||
text: "¿Cuántos hay en total?",
|
||||
answerSlot: { expects: 5 },
|
||||
grids: [
|
||||
{ id: 'pollos', mode: 'static', value: 2, label: 'Pollos', fruit: 'naranja' },
|
||||
{ id: 'hamburguesas', mode: 'static', value: 3, label: 'Hamburguesas' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── input-check: casilla de número abajo ──
|
||||
{
|
||||
exerciseType: 'input-check',
|
||||
layout: 'row',
|
||||
answerPosition: 'bottom',
|
||||
text: "¿Cuántos perros hay en el patio?",
|
||||
answerSlot: { expects: 6 },
|
||||
grids: [
|
||||
{ id: 'g1', mode: 'static', value: 4, label: 'Grupo 1', fruit: 'banana' },
|
||||
{ id: 'g2', mode: 'static', value: 2, label: 'Grupo 2', fruit: 'banana' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── sides: banca en el centro, fuentes alternando izquierda/derecha ──
|
||||
// ── sides ──
|
||||
{
|
||||
exerciseType: 'sides',
|
||||
text: "Junta los bloques de ambos lados en el centro.",
|
||||
grids: [
|
||||
{ id: 'izq1', mode: 'active', value: 3, label: 'Grupo A', fruit: 'manzana' },
|
||||
{ id: 'der1', mode: 'active', value: 2, label: 'Grupo B', fruit: 'banana' },
|
||||
{ id: 'izq2', mode: 'active', value: 2, label: 'Grupo C', fruit: 'naranja' },
|
||||
{ id: 'resp', mode: 'target', label: 'Total',
|
||||
answerSlot: { from: 'any', count: 6 } },
|
||||
{ id: 'izq1', mode: 'active', value: 3, label: 'Grupo A', image: 'manzana' },
|
||||
{ id: 'der1', mode: 'active', value: 2, label: 'Grupo B', image: 'banana' },
|
||||
{ id: 'izq2', mode: 'active', value: 1, label: 'Grupo C', image: 'naranja' },
|
||||
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 6 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ── input-check ──
|
||||
{
|
||||
exerciseType: 'input-check',
|
||||
layout: 'row', answerPosition: 'right',
|
||||
text: "¿Cuántos hay en total?",
|
||||
answerSlot: { expects: 5 },
|
||||
grids: [
|
||||
{ id: 'g1', mode: 'static', value: 2, label: 'Grupo 1', image: 'naranja' },
|
||||
{ id: 'g2', mode: 'static', value: 3, label: 'Grupo 2' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── mark-options ──
|
||||
{
|
||||
exerciseType: 'mark-options',
|
||||
layout: 'row',
|
||||
text: "¿Dónde hay menos? Marca con una X.",
|
||||
answerSlot: { correctId: 'koalas' },
|
||||
grids: [
|
||||
{ id: 'pandas', mode: 'static', value: 6, label: 'Pandas' },
|
||||
{ id: 'koalas', mode: 'static', value: 5, label: 'Koalas' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── mark-options: dos grids estáticos, el alumno marca cuál tiene menos ── { exerciseType: 'mark-options', layout: 'row', text: "¿Dónde hay menos? Marca con una X.", answerSlot: { correctId: 'koalas' }, grids: [ { id: 'pandas', mode: 'static', value: 6, label: 'Pandas' }, { id: 'koalas', mode: 'static', value: 5, label: 'Koalas' }, ], },
|
||||
]);
|
||||
|
||||
// ── USER EXERCISES ──
|
||||
@@ -325,14 +494,14 @@ const growUserArrays = (exercises) => {
|
||||
};
|
||||
|
||||
// ── COMPLETE ──
|
||||
const onComplete = (_group, _idx, _result) => {};
|
||||
const onComplete = (_group, _idx, _result) => { };
|
||||
|
||||
// ── RESET ──
|
||||
const resetAll = () => { demoKeys.value = demoKeys.value.map(k => k + 1000); };
|
||||
const resetAll = () => { demoKeys.value = demoKeys.value.map(k => k + 1000); };
|
||||
const resetAllUser = () => { userKeys.value = userKeys.value.map(k => k + 1000); };
|
||||
|
||||
// ── CREATOR ──
|
||||
const defaultGrid = () => ({ value: '', label: '', needs: '', fruit: null });
|
||||
const defaultGrid = () => ({ value: '', label: '', needs: '', image: null });
|
||||
const defaultForm = () => ({
|
||||
text: '',
|
||||
exerciseType: 'move-blocks',
|
||||
@@ -343,9 +512,9 @@ const defaultForm = () => ({
|
||||
expectedAnswer: '',
|
||||
});
|
||||
|
||||
const form = ref(defaultForm());
|
||||
const draft = ref([]);
|
||||
const lastAdded = ref(null);
|
||||
const form = ref(defaultForm());
|
||||
const draft = ref([]);
|
||||
const lastAdded = ref(null);
|
||||
const pinnedPreview = ref(null);
|
||||
|
||||
const validGrids = computed(() =>
|
||||
@@ -360,7 +529,8 @@ const canAdd = computed(() => {
|
||||
return form.value.expectedAnswer !== '' && !isNaN(Number(form.value.expectedAnswer));
|
||||
if (form.value.exerciseType === 'sides')
|
||||
return validGrids.value.some(g => parseInt(g.needs) > 0);
|
||||
return validGrids.value.length >= 2; // mark-options
|
||||
if (form.value.exerciseType === 'mark-options') return validGrids.value.length >= 2;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Grid inputs — cascade: fill → next slot appears
|
||||
@@ -400,7 +570,7 @@ const previewExercise = computed(() => {
|
||||
mode: form.value.exerciseType === 'move-blocks' ? 'active' : 'static',
|
||||
value: parseInt(g.value),
|
||||
label: g.label || String(g.value),
|
||||
...(g.fruit ? { fruit: g.fruit } : {}),
|
||||
...(g.image ? { image: g.image } : {}),
|
||||
}));
|
||||
|
||||
if (form.value.exerciseType === 'move-blocks') {
|
||||
@@ -465,9 +635,10 @@ const previewExercise = computed(() => {
|
||||
// mark-options
|
||||
return {
|
||||
exerciseType: 'mark-options',
|
||||
hideBorders: form.value.hideBorders,
|
||||
layout: 'row',
|
||||
text: form.value.text,
|
||||
answerSlot: { correctId: srcGrids[0]?.id },
|
||||
answerSlot: { correctId: srcGrids[form.value.correctGridIdx]?.id ?? srcGrids[0]?.id },
|
||||
grids: srcGrids,
|
||||
};
|
||||
});
|
||||
@@ -691,8 +862,14 @@ const loadDraft = () => {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.needs-arrow { color: #9ca3af; }
|
||||
.needs-hint { color: #9ca3af; font-size: 0.75rem; }
|
||||
.needs-arrow {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.needs-hint {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.grid-num-input {
|
||||
width: 58px;
|
||||
@@ -711,7 +888,9 @@ const loadDraft = () => {
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.grid-num-input--sm { width: 46px; }
|
||||
.grid-num-input--sm {
|
||||
width: 46px;
|
||||
}
|
||||
|
||||
.grid-label-input {
|
||||
flex: 1;
|
||||
@@ -796,8 +975,14 @@ button {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
button:hover { background: #f0f0f0; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.2rem 0.45rem;
|
||||
@@ -806,7 +991,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-add-draft { align-self: flex-start; }
|
||||
.btn-add-draft {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn-load {
|
||||
background: #6366f1;
|
||||
@@ -814,7 +1001,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-load:hover { background: #4f46e5; }
|
||||
.btn-load:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
@@ -836,9 +1025,18 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-btn:hover { background: #f0f0f0; }
|
||||
.mode-btn--active { background: #6366f1; color: white; }
|
||||
.mode-btn--active:hover { background: #4f46e5; }
|
||||
.mode-btn:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.mode-btn--active {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-btn--active:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@@ -864,18 +1062,34 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.draft-item:last-child { border-bottom: none; }
|
||||
.draft-item:hover { background: #fafbff; }
|
||||
.draft-item--pinned { background: #eef2ff; }
|
||||
.draft-nums { font-weight: 600; color: #374151; }
|
||||
.draft-dropzones { color: #9ca3af; font-size: 0.78rem; }
|
||||
.draft-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.draft-item:hover {
|
||||
background: #fafbff;
|
||||
}
|
||||
|
||||
.draft-item--pinned {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.draft-nums {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.draft-dropzones {
|
||||
color: #9ca3af;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* ── Fruit picker (select) ── */
|
||||
.fruit-select-wrap {
|
||||
.image-select-wrap {
|
||||
padding: 2px 0 4px 26px;
|
||||
}
|
||||
|
||||
.fruit-select {
|
||||
.image-select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
@@ -888,9 +1102,8 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.fruit-select:focus {
|
||||
.image-select:focus {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
470
blocksystem/src/components/exercise-types/ComposeDecompose.vue
Normal file
470
blocksystem/src/components/exercise-types/ComposeDecompose.vue
Normal file
@@ -0,0 +1,470 @@
|
||||
<script setup>
|
||||
/**
|
||||
* ComposeDecompose — "¿Qué números forman el N?"
|
||||
*
|
||||
* Schema:
|
||||
* {
|
||||
* exerciseType: 'compose-decompose',
|
||||
* text: '¿Qué números forman el 10?',
|
||||
* whole: 10,
|
||||
* numberOfOptions: 4,
|
||||
* image: 'manzana',
|
||||
* }
|
||||
*
|
||||
* Row layout:
|
||||
* Row 0: both grids shown + both inputs pre-filled (demonstration)
|
||||
* Row 1: left grid shown + left input pre-filled, right input empty
|
||||
* Row 2+: both grids shown, both inputs empty
|
||||
*
|
||||
* Hint table fills in real-time as the student types — never shows answer upfront.
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import Blocks from '../Blocks.vue';
|
||||
|
||||
const props = defineProps({
|
||||
exercise: { type: Object, required: true },
|
||||
});
|
||||
const emit = defineEmits(['complete']);
|
||||
|
||||
const whole = computed(() => props.exercise.whole ?? 10);
|
||||
const image = computed(() => props.exercise.image ?? null);
|
||||
const count = computed(() => props.exercise.numberOfOptions ?? 4);
|
||||
|
||||
// Auto-generate rows: 9+1, 8+2, 7+3, 6+4...
|
||||
const rows = computed(() =>
|
||||
Array.from({ length: count.value }, (_, i) => ({
|
||||
left: whole.value - (i + 1),
|
||||
right: i + 1,
|
||||
}))
|
||||
);
|
||||
|
||||
// Determine display mode per row:
|
||||
// 'full' → both pre-filled (row 0)
|
||||
// 'left' → left pre-filled, right is input (row 1)
|
||||
// 'empty' → both are inputs (row 2+)
|
||||
const rowMode = (idx) => {
|
||||
if (idx === 0) return 'full';
|
||||
if (idx === 1) return 'left';
|
||||
return 'empty';
|
||||
};
|
||||
|
||||
// Per-row input state
|
||||
const makeState = () => rows.value.map((row, idx) => ({
|
||||
leftInput: idx === 0 ? String(row.left) : (idx === 1 ? String(row.left) : ''),
|
||||
rightInput: idx === 0 ? String(row.right) : '',
|
||||
result: idx === 0 ? 'correct' : null,
|
||||
}));
|
||||
|
||||
const rowState = ref(makeState());
|
||||
|
||||
watch(() => props.exercise, () => { rowState.value = makeState(); }, { deep: true });
|
||||
|
||||
// Hint values: show what the student typed, or '?' if empty
|
||||
// For pre-filled rows: show the correct value
|
||||
const hintLeft = (idx) => {
|
||||
const mode = rowMode(idx);
|
||||
if (mode === 'full' || mode === 'left') return rows.value[idx].left;
|
||||
const v = Number(rowState.value[idx].leftInput);
|
||||
return rowState.value[idx].leftInput !== '' ? v : null;
|
||||
};
|
||||
|
||||
const hintRight = (idx) => {
|
||||
const mode = rowMode(idx);
|
||||
if (mode === 'full') return rows.value[idx].right;
|
||||
const v = Number(rowState.value[idx].rightInput);
|
||||
return rowState.value[idx].rightInput !== '' ? v : null;
|
||||
};
|
||||
|
||||
// Global confirmation — checks all editable rows at once
|
||||
const globalResult = ref(null); // null | 'correct' | 'incorrect'
|
||||
|
||||
const confirmAll = () => {
|
||||
if (globalResult.value === 'correct') return;
|
||||
|
||||
rows.value.forEach((row, idx) => {
|
||||
const state = rowState.value[idx];
|
||||
if (rowMode(idx) === 'full') return; // skip demo row
|
||||
const leftOk = rowMode(idx) === 'left'
|
||||
? true
|
||||
: Number(state.leftInput) === row.left;
|
||||
const rightOk = Number(state.rightInput) === row.right;
|
||||
state.result = (leftOk && rightOk) ? 'correct' : 'incorrect';
|
||||
});
|
||||
|
||||
const allCorrect = rowState.value
|
||||
.filter((_, idx) => rowMode(idx) !== 'full')
|
||||
.every(s => s.result === 'correct');
|
||||
|
||||
globalResult.value = allCorrect ? 'correct' : 'incorrect';
|
||||
|
||||
if (allCorrect) {
|
||||
emit('complete', { correct: true });
|
||||
}
|
||||
// Incorrect rows stay visible so the student can see what they got wrong.
|
||||
};
|
||||
|
||||
// Hint color
|
||||
const COLORS = {
|
||||
1: '#EF4444', 2: '#F59E0B', 3: '#10B981', 4: '#3B82F6',
|
||||
5: '#8B5CF6', 6: '#EC4899', 7: '#06B6D4', 8: '#F97316', 9: '#14B8A6',
|
||||
};
|
||||
const hintBg = (n) => n != null && COLORS[n] ? COLORS[n] + '33' : '#f1f5f9';
|
||||
|
||||
// Each grid uses a natural shape, but both grids in a row
|
||||
// share the same _cellSize so they render at the same physical size.
|
||||
// _cellSize is passed explicitly to override getCellSize in Blocks.vue.
|
||||
// Single row, fixed cell size so both grids have the same height.
|
||||
// 36px gives good visual weight without being too large.
|
||||
const sharedCellSize = computed(() => 36);
|
||||
|
||||
const makeGridExercise = (value) => {
|
||||
const v = Math.max(1, value);
|
||||
return {
|
||||
exerciseType: 'input-check',
|
||||
hideBorders: false,
|
||||
showCellBorders: true,
|
||||
answerPosition: 'right',
|
||||
answerSlot: { expects: -1 },
|
||||
_gridCols: v,
|
||||
_gridRows: 1,
|
||||
_cellSize: sharedCellSize.value,
|
||||
grids: [{
|
||||
id: 'g0',
|
||||
mode: 'static',
|
||||
value: v,
|
||||
label: '',
|
||||
image: image.value ?? null,
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const correctCount = computed(() => rowState.value.filter(s => s.result === 'correct').length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cd-card">
|
||||
|
||||
<div v-if="exercise.text" class="cd-header">
|
||||
<p class="cd-question">{{ exercise.text }}</p>
|
||||
</div>
|
||||
|
||||
<div v-for="(row, idx) in rows" :key="idx" :class="['cd-row', {
|
||||
'cd-row--correct': rowState[idx].result === 'correct',
|
||||
'cd-row--incorrect': rowState[idx].result === 'incorrect',
|
||||
'cd-row--demo': rowMode(idx) === 'full',
|
||||
}]">
|
||||
<!-- Left visual grid -->
|
||||
<div class="cd-visual">
|
||||
<div class="cd-grid-wrap">
|
||||
<Blocks :exercise="makeGridExercise(row.left)" :key="`l-${idx}-${row.left}`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="cd-sep">y</span>
|
||||
|
||||
<!-- Right visual grid -->
|
||||
<div class="cd-visual">
|
||||
<div class="cd-grid-wrap">
|
||||
<Blocks :exercise="makeGridExercise(row.right)" :key="`r-${idx}-${row.right}`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: inputs -->
|
||||
<div class="cd-inputs">
|
||||
<!-- Left input: editable only on 'empty' rows -->
|
||||
<input v-if="rowMode(idx) === 'empty'" class="cd-input" :class="{
|
||||
'cd-input--correct': rowState[idx].result === 'correct',
|
||||
'cd-input--incorrect': rowState[idx].result === 'incorrect',
|
||||
}" type="number" min="0" :max="whole" v-model="rowState[idx].leftInput" placeholder="?" />
|
||||
<!-- Left pre-filled display -->
|
||||
<div v-else class="cd-input cd-input--prefilled" :class="{ 'cd-input--correct': rowMode(idx) === 'full' }">
|
||||
{{ row.left }}
|
||||
</div>
|
||||
|
||||
<!-- Right input: editable on 'left' and 'empty' rows -->
|
||||
<input v-if="rowMode(idx) !== 'full'" class="cd-input" :class="{
|
||||
'cd-input--correct': rowState[idx].result === 'correct',
|
||||
'cd-input--incorrect': rowState[idx].result === 'incorrect',
|
||||
}" type="number" min="0" :max="whole" v-model="rowState[idx].rightInput" placeholder="?" />
|
||||
<!-- Right pre-filled display -->
|
||||
<div v-else class="cd-input cd-input--prefilled cd-input--correct">
|
||||
{{ row.right }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint table: fills as student types, never reveals answer -->
|
||||
<div class="cd-hint">
|
||||
<div class="cd-hint-top">{{ whole }}</div>
|
||||
<div class="cd-hint-bottom">
|
||||
<div class="cd-hint-cell" :style="{ background: hintBg(hintLeft(idx)) }">
|
||||
{{ hintLeft(idx) ?? '?' }}
|
||||
</div>
|
||||
<div class="cd-hint-cell" :style="{ background: hintBg(hintRight(idx)) }">
|
||||
{{ hintRight(idx) ?? '?' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="cd-footer">
|
||||
<span class="cd-progress">{{ correctCount }} / {{ rows.length }}</span>
|
||||
<button class="cd-confirm-all" :class="{
|
||||
'cd-confirm-all--correct': globalResult === 'correct',
|
||||
'cd-confirm-all--incorrect': globalResult === 'incorrect',
|
||||
}" @click="confirmAll" :disabled="globalResult === 'correct'">
|
||||
{{ globalResult === 'correct' ? '✓ Correcto' : globalResult === 'incorrect' ? '✗ Inténtalo' : 'Confirmar' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cd-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.cd-header {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.cd-question {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Row ── */
|
||||
.cd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.cd-row--correct {
|
||||
border-color: #10b981;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.cd-row--incorrect {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
animation: shake 0.3s ease;
|
||||
}
|
||||
|
||||
.cd-row--demo {
|
||||
background: #fafbff;
|
||||
border-color: #e0e7ff;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Visual grids: strip Blocks.vue chrome ── */
|
||||
.cd-visual {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cd-grid-wrap :deep(.ex-card) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.cd-grid-wrap :deep(.ex-footer),
|
||||
.cd-grid-wrap :deep(.ex-header),
|
||||
.cd-grid-wrap :deep(.input-answer-slot),
|
||||
.cd-grid-wrap :deep(.btn-reset) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cd-grid-wrap :deep(.ex-body) {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.cd-grid-wrap :deep(.zone-sources) {
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.cd-sep {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Inputs centered ── */
|
||||
.cd-inputs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.cd-input {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #1e1b4b;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
-moz-appearance: textfield;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cd-input::-webkit-outer-spin-button,
|
||||
.cd-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cd-input:focus {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.cd-input--prefilled {
|
||||
cursor: default;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.cd-input--correct {
|
||||
border-color: #10b981 !important;
|
||||
background: #f0fdf4;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.cd-input--incorrect {
|
||||
border-color: #ef4444 !important;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
/* ── Hint table ── */
|
||||
.cd-hint {
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
min-width: 76px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cd-hint-top {
|
||||
background: #fef9c3;
|
||||
text-align: center;
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1.5px solid #cbd5e1;
|
||||
color: #92400e;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cd-hint-bottom {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cd-hint-cell {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 3px 6px;
|
||||
color: #374151;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cd-hint-cell:first-child {
|
||||
border-right: 1.5px solid #cbd5e1;
|
||||
}
|
||||
|
||||
/* ── Global confirm button ── */
|
||||
.cd-confirm-all {
|
||||
padding: 0.45rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid #6366f1;
|
||||
background: white;
|
||||
color: #6366f1;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.cd-confirm-all:hover:not(:disabled) {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cd-confirm-all:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cd-confirm-all--correct {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cd-confirm-all--incorrect {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.cd-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.cd-progress {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
658
blocksystem/src/components/exercise-types/Connectlines.vue
Normal file
658
blocksystem/src/components/exercise-types/Connectlines.vue
Normal file
@@ -0,0 +1,658 @@
|
||||
<script setup>
|
||||
/**
|
||||
* ConnectLines — "Conecta"
|
||||
*
|
||||
* Schema (2 columns):
|
||||
* {
|
||||
* exerciseType: 'connect',
|
||||
* text: 'Conecta.',
|
||||
* columns: 2,
|
||||
* leftItems: [{ id, grids: [{ value, image? }] }],
|
||||
* rightItems: [{ id, grids: [{ value, image? }] }],
|
||||
* correctPairs: [{ left, right }]
|
||||
* }
|
||||
*
|
||||
* Schema (3 columns):
|
||||
* {
|
||||
* columns: 3,
|
||||
* leftItems: [...],
|
||||
* centerItems: [...],
|
||||
* rightItems: [...],
|
||||
* correctPairs: [{ left, center }, { right, center }]
|
||||
* }
|
||||
*
|
||||
* Interaction:
|
||||
* - Drag from dot on right edge of left item → dot on left edge of right item
|
||||
* - For 3 cols: left→center and right→center independently
|
||||
* - Each item can only have one connection per side
|
||||
* - Click a line to delete it
|
||||
* - Confirmar validates all pairs at once
|
||||
*/
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import Blocks from '../Blocks.vue';
|
||||
|
||||
const props = defineProps({ exercise: { type: Object, required: true } });
|
||||
const emit = defineEmits(['complete']);
|
||||
|
||||
const columns = computed(() => props.exercise.columns ?? 2);
|
||||
const leftItems = computed(() => props.exercise.leftItems ?? []);
|
||||
const centerItems = computed(() => props.exercise.centerItems ?? []);
|
||||
const rightItems = computed(() => props.exercise.rightItems ?? []);
|
||||
const correctPairs = computed(() => props.exercise.correctPairs ?? []);
|
||||
|
||||
// ── Lines state ──
|
||||
// Each line: { id, fromId, toId, fromSide:'right'|'left', toSide:'left'|'right' }
|
||||
const lines = ref([]);
|
||||
let lineId = 0;
|
||||
|
||||
// ── Column map: which column does each item belong to ──
|
||||
const columnMap = computed(() => {
|
||||
const map = {};
|
||||
leftItems.value.forEach(i => { map[i.id] = 'left'; });
|
||||
centerItems.value.forEach(i => { map[i.id] = 'center'; });
|
||||
rightItems.value.forEach(i => { map[i.id] = 'right'; });
|
||||
return map;
|
||||
});
|
||||
|
||||
// Valid connection: left↔center or right↔center (never left↔right directly)
|
||||
const isValidConnection = (fromId, toId) => {
|
||||
if (columns.value !== 3) return true; // 2-col: always valid
|
||||
const from = columnMap.value[fromId];
|
||||
const to = columnMap.value[toId];
|
||||
return (from === 'left' && to === 'center') ||
|
||||
(from === 'center' && to === 'left') ||
|
||||
(from === 'right' && to === 'center') ||
|
||||
(from === 'center' && to === 'right');
|
||||
};
|
||||
|
||||
// ── Drag state ──
|
||||
const dragging = ref(null); // { fromId, fromSide, startX, startY, currentX, currentY }
|
||||
|
||||
// ── Result ──
|
||||
const result = ref(null); // null | 'correct' | 'incorrect'
|
||||
|
||||
// ── DOM refs for dot positions ──
|
||||
const containerRef = ref(null);
|
||||
const dotRefs = ref({}); // { 'itemId-side': el }
|
||||
|
||||
const setDotRef = (id, side, el) => {
|
||||
if (el) dotRefs.value[`${id}-${side}`] = el;
|
||||
};
|
||||
|
||||
// Get center position of a dot relative to the SVG container
|
||||
const getDotPos = (id, side) => {
|
||||
const key = `${id}-${side}`;
|
||||
const el = dotRefs.value[key];
|
||||
const con = containerRef.value;
|
||||
if (!el || !con) return null;
|
||||
const er = el.getBoundingClientRect();
|
||||
const cr = con.getBoundingClientRect();
|
||||
return {
|
||||
x: er.left + er.width / 2 - cr.left,
|
||||
y: er.top + er.height / 2 - cr.top,
|
||||
};
|
||||
};
|
||||
|
||||
// ── Line management ──
|
||||
const getLineFrom = (fromId) => lines.value.find(l => l.fromId === fromId);
|
||||
const getLineTo = (toId) => lines.value.find(l => l.toId === toId);
|
||||
|
||||
const removeLine = (id) => {
|
||||
lines.value = lines.value.filter(l => l.id !== id);
|
||||
result.value = null;
|
||||
};
|
||||
|
||||
const startDrag = (e, itemId, side) => {
|
||||
if (result.value === 'correct') return;
|
||||
e.preventDefault();
|
||||
const pos = getDotPos(itemId, side);
|
||||
if (!pos) return;
|
||||
|
||||
// Remove existing line from this dot
|
||||
if (side === 'right') lines.value = lines.value.filter(l => l.fromId !== itemId);
|
||||
if (side === 'left') lines.value = lines.value.filter(l => l.toId !== itemId);
|
||||
|
||||
result.value = null;
|
||||
dragging.value = {
|
||||
fromId: itemId,
|
||||
fromSide: side,
|
||||
startX: pos.x,
|
||||
startY: pos.y,
|
||||
currentX: pos.x,
|
||||
currentY: pos.y,
|
||||
};
|
||||
};
|
||||
|
||||
const SNAP_RADIUS = 40; // px — snap to dot if within this distance
|
||||
|
||||
const getTargetDots = () => {
|
||||
const d = dragging.value;
|
||||
if (!d) return [];
|
||||
const targetSide = d.fromSide === 'right' ? 'left' : 'right';
|
||||
let items = [];
|
||||
if (d.fromSide === 'right') items = [...rightItems.value, ...centerItems.value];
|
||||
else items = [...leftItems.value, ...centerItems.value];
|
||||
return items
|
||||
.filter(item => isValidConnection(d.fromId, item.id))
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
side: targetSide,
|
||||
pos: getDotPos(item.id, targetSide),
|
||||
})).filter(t => t.pos);
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!dragging.value || !containerRef.value) return;
|
||||
const cr = containerRef.value.getBoundingClientRect();
|
||||
const mx = e.clientX - cr.left;
|
||||
const my = e.clientY - cr.top;
|
||||
|
||||
// Find nearest valid target dot within snap radius
|
||||
let snapped = null;
|
||||
let minDist = SNAP_RADIUS;
|
||||
for (const t of getTargetDots()) {
|
||||
const dx = t.pos.x - mx;
|
||||
const dy = t.pos.y - my;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < minDist) { minDist = dist; snapped = t; }
|
||||
}
|
||||
|
||||
if (snapped) {
|
||||
dragging.value.currentX = snapped.pos.x;
|
||||
dragging.value.currentY = snapped.pos.y;
|
||||
dragging.value.snapTarget = snapped.id;
|
||||
} else {
|
||||
dragging.value.currentX = mx;
|
||||
dragging.value.currentY = my;
|
||||
dragging.value.snapTarget = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (e, targetId, targetSide) => {
|
||||
if (!dragging.value) return;
|
||||
const d = dragging.value;
|
||||
|
||||
// Use snapped target if available, otherwise use the element under cursor
|
||||
let resolvedId = targetId;
|
||||
let resolvedSide = targetSide;
|
||||
|
||||
if (d.snapTarget && !targetId) {
|
||||
resolvedId = d.snapTarget;
|
||||
resolvedSide = d.fromSide === 'right' ? 'left' : 'right';
|
||||
}
|
||||
|
||||
const validDrop =
|
||||
(d.fromSide === 'right' && resolvedSide === 'left') ||
|
||||
(d.fromSide === 'left' && resolvedSide === 'right');
|
||||
|
||||
if (validDrop && resolvedId && resolvedId !== d.fromId && isValidConnection(d.fromId, resolvedId)) {
|
||||
if (resolvedSide === 'left') lines.value = lines.value.filter(l => l.toId !== resolvedId);
|
||||
if (resolvedSide === 'right') lines.value = lines.value.filter(l => l.fromId !== resolvedId);
|
||||
|
||||
const fromId = d.fromSide === 'right' ? d.fromId : resolvedId;
|
||||
const toId = d.fromSide === 'right' ? resolvedId : d.fromId;
|
||||
|
||||
lines.value.push({ id: lineId++, fromId, toId });
|
||||
}
|
||||
|
||||
dragging.value = null;
|
||||
};
|
||||
|
||||
// Global mouseup also tries snap
|
||||
const cancelDrag = (e) => {
|
||||
if (!dragging.value) return;
|
||||
const d = dragging.value;
|
||||
|
||||
// If there's a snap target, complete the connection on global mouseup too
|
||||
if (d.snapTarget && isValidConnection(d.fromId, d.snapTarget)) {
|
||||
const resolvedSide = d.fromSide === 'right' ? 'left' : 'right';
|
||||
if (resolvedSide === 'left') lines.value = lines.value.filter(l => l.toId !== d.snapTarget);
|
||||
if (resolvedSide === 'right') lines.value = lines.value.filter(l => l.fromId !== d.snapTarget);
|
||||
|
||||
const fromId = d.fromSide === 'right' ? d.fromId : d.snapTarget;
|
||||
const toId = d.fromSide === 'right' ? d.snapTarget : d.fromId;
|
||||
|
||||
lines.value.push({ id: lineId++, fromId, toId });
|
||||
}
|
||||
|
||||
dragging.value = null;
|
||||
};
|
||||
|
||||
// ── Confirm ──
|
||||
const confirm = () => {
|
||||
if (result.value === 'correct') return;
|
||||
|
||||
const allCorrect = correctPairs.value.every(pair => {
|
||||
if (pair.left && pair.right) {
|
||||
return lines.value.some(l => l.fromId === pair.left && l.toId === pair.right);
|
||||
}
|
||||
if (pair.left && pair.center) {
|
||||
return lines.value.some(l => l.fromId === pair.left && l.toId === pair.center);
|
||||
}
|
||||
if (pair.right && pair.center) {
|
||||
return lines.value.some(l => l.fromId === pair.center && l.toId === pair.right) ||
|
||||
lines.value.some(l => l.fromId === pair.right && l.toId === pair.center);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
result.value = allCorrect ? 'correct' : 'incorrect';
|
||||
if (allCorrect) emit('complete', { correct: true });
|
||||
};
|
||||
|
||||
// ── Computed SVG lines ──
|
||||
const svgLines = computed(() => {
|
||||
return lines.value.map(l => {
|
||||
const from = getDotPos(l.fromId, 'right');
|
||||
const to = getDotPos(l.toId, 'left');
|
||||
if (!from || !to) return null;
|
||||
return { ...l, x1: from.x, y1: from.y, x2: to.x, y2: to.y };
|
||||
}).filter(Boolean);
|
||||
});
|
||||
|
||||
const dragLine = computed(() => {
|
||||
if (!dragging.value) return null;
|
||||
return {
|
||||
x1: dragging.value.startX,
|
||||
y1: dragging.value.startY,
|
||||
x2: dragging.value.currentX,
|
||||
y2: dragging.value.currentY,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Line status for color ──
|
||||
const lineStatus = (line) => {
|
||||
if (!result.value) return 'neutral';
|
||||
const correct = correctPairs.value.some(p =>
|
||||
(p.left === line.fromId && p.right === line.toId) ||
|
||||
(p.left === line.fromId && p.center === line.toId) ||
|
||||
(p.right === line.toId && p.center === line.fromId)
|
||||
);
|
||||
return correct ? 'correct' : 'incorrect';
|
||||
};
|
||||
|
||||
// ── Grid exercise builder (stable — called once per item) ──
|
||||
function buildGridEx(value, image, color) {
|
||||
const v = Math.max(1, value);
|
||||
const cols = v <= 3 ? v : 2;
|
||||
const rows = Math.ceil(v / cols);
|
||||
return {
|
||||
exerciseType: 'input-check',
|
||||
hideBorders: false,
|
||||
showCellBorders: true,
|
||||
answerPosition: 'right',
|
||||
answerSlot: { expects: -1 },
|
||||
_gridCols: cols,
|
||||
_gridRows: rows,
|
||||
_blockColor: color ?? null,
|
||||
grids: [{ id: 'g0', mode: 'static', value: v, label: '', image: image ?? null }],
|
||||
};
|
||||
}
|
||||
|
||||
const itemGridExercises = computed(() => {
|
||||
const result = {};
|
||||
[...leftItems.value, ...centerItems.value, ...rightItems.value].forEach(item => {
|
||||
result[item.id] = (item.grids ?? []).map(g => buildGridEx(g.value, g.image, g.color));
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
// ── Event listeners ──
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', cancelDrag);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', cancelDrag);
|
||||
});
|
||||
|
||||
// ── Dot connected state ──
|
||||
const isDotConnected = (itemId, side) => {
|
||||
if (side === 'right') return lines.value.some(l => l.fromId === itemId);
|
||||
if (side === 'left') return lines.value.some(l => l.toId === itemId);
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cl-card">
|
||||
|
||||
<div v-if="exercise.text" class="cl-header">
|
||||
<p class="cl-question">{{ exercise.text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Connect area -->
|
||||
<div class="cl-body" ref="containerRef" @mousemove="onMouseMove">
|
||||
|
||||
<!-- SVG overlay for lines -->
|
||||
<svg class="cl-svg" @mousemove="onMouseMove">
|
||||
<!-- Completed lines -->
|
||||
<line v-for="line in svgLines" :key="line.id" :x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2"
|
||||
:class="['cl-line', `cl-line--${lineStatus(line)}`]" @click="removeLine(line.id)" />
|
||||
<!-- Drag preview line -->
|
||||
<line v-if="dragLine" :x1="dragLine.x1" :y1="dragLine.y1" :x2="dragLine.x2" :y2="dragLine.y2"
|
||||
class="cl-line cl-line--drag" />
|
||||
</svg>
|
||||
|
||||
<!-- Left column -->
|
||||
<div class="cl-col cl-col--left">
|
||||
<div v-for="item in leftItems" :key="item.id" class="cl-item cl-item--left">
|
||||
<!-- Label or Grids -->
|
||||
<div v-if="item.label" class="cl-item-label">{{ item.label }}</div>
|
||||
<div v-else class="cl-item-grids">
|
||||
<div v-for="(gex, gi) in itemGridExercises[item.id]" :key="gi" class="cl-grid-wrap">
|
||||
<Blocks :exercise="gex" :key="`${item.id}-g${gi}`" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right dot -->
|
||||
<div :ref="el => setDotRef(item.id, 'right', el)"
|
||||
:class="['cl-dot', 'cl-dot--right', { 'cl-dot--connected': isDotConnected(item.id, 'right'), 'cl-dot--snap': dragging && dragging.snapTarget === item.id }]"
|
||||
@mousedown.stop="e => startDrag(e, item.id, 'right')" @mouseup.stop="e => onMouseUp(e, item.id, 'right')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center column (3-col variant) -->
|
||||
<div v-if="columns === 3" class="cl-col cl-col--center">
|
||||
<div v-for="item in centerItems" :key="item.id" class="cl-item cl-item--center">
|
||||
<div :ref="el => setDotRef(item.id, 'left', el)"
|
||||
:class="['cl-dot', 'cl-dot--left', { 'cl-dot--connected': isDotConnected(item.id, 'left'), 'cl-dot--snap': dragging && dragging.snapTarget === item.id }]"
|
||||
@mousedown.stop="e => startDrag(e, item.id, 'left')" @mouseup.stop="e => onMouseUp(e, item.id, 'left')" />
|
||||
<div class="cl-item-grids">
|
||||
<div v-for="(gex, gi) in itemGridExercises[item.id]" :key="gi" class="cl-grid-wrap">
|
||||
<Blocks :exercise="gex" :key="`${item.id}-g${gi}`" />
|
||||
</div>
|
||||
</div>
|
||||
<div :ref="el => setDotRef(item.id, 'right', el)"
|
||||
:class="['cl-dot', 'cl-dot--right', { 'cl-dot--connected': isDotConnected(item.id, 'right'), 'cl-dot--snap': dragging && dragging.snapTarget === item.id }]"
|
||||
@mousedown.stop="e => startDrag(e, item.id, 'right')" @mouseup.stop="e => onMouseUp(e, item.id, 'right')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column -->
|
||||
<div class="cl-col cl-col--right">
|
||||
<div v-for="item in rightItems" :key="item.id" class="cl-item cl-item--right">
|
||||
<!-- Left dot -->
|
||||
<div :ref="el => setDotRef(item.id, 'left', el)"
|
||||
:class="['cl-dot', 'cl-dot--left', { 'cl-dot--connected': isDotConnected(item.id, 'left'), 'cl-dot--snap': dragging && dragging.snapTarget === item.id }]"
|
||||
@mousedown.stop="e => startDrag(e, item.id, 'left')" @mouseup.stop="e => onMouseUp(e, item.id, 'left')" />
|
||||
<!-- Label or Grids -->
|
||||
<div v-if="item.label" class="cl-item-label">{{ item.label }}</div>
|
||||
<div v-else class="cl-item-grids">
|
||||
<div v-for="(gex, gi) in itemGridExercises[item.id]" :key="gi" class="cl-grid-wrap">
|
||||
<Blocks :exercise="gex" :key="`${item.id}-g${gi}`" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="cl-footer">
|
||||
<button class="cl-confirm" :class="{
|
||||
'cl-confirm--correct': result === 'correct',
|
||||
'cl-confirm--incorrect': result === 'incorrect',
|
||||
}" @click="confirm" :disabled="result === 'correct'">
|
||||
{{ result === 'correct' ? '✓ Correcto' : result === 'incorrect' ? '✗ Inténtalo' : 'Confirmar' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cl-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
font-family: system-ui, sans-serif;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cl-header {
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.cl-question {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Connect area ── */
|
||||
.cl-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 0;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* SVG sits on top of everything */
|
||||
.cl-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.cl-svg line {
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Columns ── */
|
||||
.cl-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
gap: 12px;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cl-col--left {
|
||||
align-items: flex-end;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.cl-col--center {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cl-col--right {
|
||||
align-items: flex-start;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
/* ── Items ── */
|
||||
.cl-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #e5e7eb;
|
||||
background: white;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cl-item--left {
|
||||
background: #fff7ed;
|
||||
border-color: #fed7aa;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cl-item--right {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cl-item--center {
|
||||
background: #fdf4ff;
|
||||
border-color: #e9d5ff;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Item grids ── */
|
||||
.cl-item-grids {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Label items ── */
|
||||
.cl-item-label {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Strip Blocks.vue chrome */
|
||||
.cl-grid-wrap :deep(.ex-card) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.cl-grid-wrap :deep(.ex-footer),
|
||||
.cl-grid-wrap :deep(.ex-header),
|
||||
.cl-grid-wrap :deep(.input-answer-slot),
|
||||
.cl-grid-wrap :deep(.btn-reset) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cl-grid-wrap :deep(.ex-body) {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.cl-grid-wrap :deep(.zone-sources) {
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* ── Dots ── */
|
||||
.cl-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 0 1.5px #94a3b8;
|
||||
cursor: crosshair;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
z-index: 20;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cl-dot:hover {
|
||||
background: #6366f1;
|
||||
box-shadow: 0 0 0 1.5px #6366f1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.cl-dot--connected {
|
||||
background: #6366f1;
|
||||
box-shadow: 0 0 0 1.5px #6366f1;
|
||||
}
|
||||
|
||||
.cl-dot--snap {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.3);
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
/* ── SVG lines ── */
|
||||
:deep(.cl-line) {
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.cl-line--neutral {
|
||||
stroke: #6366f1;
|
||||
}
|
||||
|
||||
.cl-line--correct {
|
||||
stroke: #10b981;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.cl-line--incorrect {
|
||||
stroke: #ef4444;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.cl-line--drag {
|
||||
stroke: #94a3b8;
|
||||
stroke-dasharray: 6 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.cl-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.cl-confirm {
|
||||
padding: 0.45rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid #6366f1;
|
||||
background: white;
|
||||
color: #6366f1;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.cl-confirm:hover:not(:disabled) {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cl-confirm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cl-confirm--correct {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cl-confirm--incorrect {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
314
blocksystem/src/components/exercise-types/Picturechoose.vue
Normal file
314
blocksystem/src/components/exercise-types/Picturechoose.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script setup>
|
||||
/**
|
||||
* PictureChoose — "Escoge la imagen correcta"
|
||||
*
|
||||
* Schema:
|
||||
* {
|
||||
* exerciseType: 'picture-choose',
|
||||
* text: 'Hay 5 flores rojas y 4 flores blancas. ¿Cuántas hay en total?',
|
||||
* firstImage: 'manzana', // image for left grid of each option
|
||||
* secondImage: 'frutilla', // image for right grid of each option
|
||||
* options: [
|
||||
* { first: 5, second: 4, correct: true },
|
||||
* { first: 3, second: 4, correct: false },
|
||||
* { first: 5, second: 2, correct: false },
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Each option shows two grids side by side + a checkbox below.
|
||||
* Student selects one option and confirms with the global button.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import Blocks from '../Blocks.vue';
|
||||
|
||||
const props = defineProps({
|
||||
exercise: { type: Object, required: true },
|
||||
});
|
||||
const emit = defineEmits(['complete']);
|
||||
|
||||
const options = computed(() => props.exercise.options ?? []);
|
||||
const firstImage = computed(() => props.exercise.firstImage ?? null);
|
||||
const secondImage = computed(() => props.exercise.secondImage ?? null);
|
||||
|
||||
const selected = ref(null); // index of selected option
|
||||
const result = ref(null); // null | 'correct' | 'incorrect'
|
||||
|
||||
const select = (idx) => {
|
||||
if (result.value === 'correct') return;
|
||||
selected.value = idx;
|
||||
result.value = null; // clear previous result on new selection
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (selected.value === null || result.value === 'correct') return;
|
||||
const correct = options.value[selected.value]?.correct ?? false;
|
||||
result.value = correct ? 'correct' : 'incorrect';
|
||||
if (correct) emit('complete', { correct: true });
|
||||
};
|
||||
|
||||
// Pre-build all grid exercises as stable computed objects
|
||||
// so Blocks.vue watch doesn't trigger on every selection change.
|
||||
function buildGridEx(value, image) {
|
||||
const v = Math.max(1, value);
|
||||
const cols = v <= 3 ? v : 2;
|
||||
const rows = Math.ceil(v / cols);
|
||||
return {
|
||||
exerciseType: 'input-check',
|
||||
hideBorders: false,
|
||||
showCellBorders: true,
|
||||
answerPosition: 'right',
|
||||
answerSlot: { expects: -1 },
|
||||
_gridCols: cols,
|
||||
_gridRows: rows,
|
||||
grids: [{ id: 'g0', mode: 'static', value: v, label: '', image: image ?? null }],
|
||||
};
|
||||
}
|
||||
|
||||
const gridExercises = computed(() =>
|
||||
options.value.map(opt => ({
|
||||
first: buildGridEx(opt.first, firstImage.value),
|
||||
second: buildGridEx(opt.second, secondImage.value),
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pc-card">
|
||||
|
||||
<!-- Header -->
|
||||
<div v-if="exercise.text" class="pc-header">
|
||||
<p class="pc-question">{{ exercise.text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="pc-options">
|
||||
<div v-for="(opt, idx) in options" :key="idx" :class="['pc-option', {
|
||||
'pc-option--selected': selected === idx,
|
||||
'pc-option--correct': result && selected === idx && opt.correct,
|
||||
'pc-option--incorrect': result && selected === idx && !opt.correct,
|
||||
'pc-option--disabled': result === 'correct' && selected !== idx,
|
||||
}]" @click="select(idx)">
|
||||
<!-- Checkbox left -->
|
||||
<div class="pc-checkbox-row">
|
||||
<div :class="['pc-checkbox', {
|
||||
'pc-checkbox--checked': selected === idx,
|
||||
'pc-checkbox--correct': result && selected === idx && opt.correct,
|
||||
'pc-checkbox--incorrect': result && selected === idx && !opt.correct,
|
||||
}]">
|
||||
<span v-if="result && selected === idx && opt.correct">✓</span>
|
||||
<span v-else-if="result && selected === idx && !opt.correct">✕</span>
|
||||
<span v-else-if="selected === idx">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two grids centered -->
|
||||
<div class="pc-grids">
|
||||
<div class="pc-grid-wrap">
|
||||
<Blocks :exercise="gridExercises[idx].first" :key="`opt-${idx}-first-${opt.first}`" />
|
||||
</div>
|
||||
<div class="pc-grid-wrap">
|
||||
<Blocks :exercise="gridExercises[idx].second" :key="`opt-${idx}-second-${opt.second}`" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="pc-footer">
|
||||
<button class="pc-confirm" :class="{
|
||||
'pc-confirm--correct': result === 'correct',
|
||||
'pc-confirm--incorrect': result === 'incorrect',
|
||||
}" @click="confirm" :disabled="selected === null || result === 'correct'">
|
||||
{{ result === 'correct' ? '✓ Correcto' : result === 'incorrect' ? '✗ Inténtalo' : 'Confirmar' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pc-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.pc-header {
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.pc-question {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Options list ── */
|
||||
.pc-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pc-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pc-option:hover:not(.pc-option--disabled) {
|
||||
border-color: #a5b4fc;
|
||||
background: #fafbff;
|
||||
}
|
||||
|
||||
.pc-option--selected {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.pc-option--correct {
|
||||
border-color: #10b981;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.pc-option--incorrect {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.pc-option--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Grids inside option ── */
|
||||
.pc-grids {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Strip Blocks.vue card chrome */
|
||||
.pc-grid-wrap :deep(.ex-card) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.pc-grid-wrap :deep(.ex-footer),
|
||||
.pc-grid-wrap :deep(.ex-header),
|
||||
.pc-grid-wrap :deep(.input-answer-slot),
|
||||
.pc-grid-wrap :deep(.btn-reset) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pc-grid-wrap :deep(.ex-body) {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.pc-grid-wrap :deep(.zone-sources) {
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* ── Checkbox ── */
|
||||
.pc-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pc-checkbox {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #d1d5db;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.pc-checkbox--checked {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.pc-checkbox--correct {
|
||||
border-color: #10b981;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pc-checkbox--incorrect {
|
||||
border-color: #ef4444;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.pc-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.pc-confirm {
|
||||
padding: 0.45rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid #6366f1;
|
||||
background: white;
|
||||
color: #6366f1;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.pc-confirm:hover:not(:disabled) {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pc-confirm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pc-confirm--correct {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pc-confirm--incorrect {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
354
blocksystem/src/components/exercise-types/Subtractvisual.vue
Normal file
354
blocksystem/src/components/exercise-types/Subtractvisual.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup>
|
||||
/**
|
||||
* SubtractVisual — "¿Cuántos quedan?"
|
||||
*
|
||||
* Schema:
|
||||
* {
|
||||
* exerciseType: 'subtract-visual',
|
||||
* text: 'Hay 5 peces. Diego sacó 2. ¿Cuántos quedan?',
|
||||
* initialQuantity: 5,
|
||||
* removedQuantity: 2,
|
||||
* image: 'manzana', // optional image for both grids
|
||||
* options: [
|
||||
* { id: 'o1', value: 3 },
|
||||
* { id: 'o2', value: 4 },
|
||||
* { id: 'o3', value: 2 },
|
||||
* { id: 'o4', value: 1 },
|
||||
* ],
|
||||
* correctAnswer: 3,
|
||||
* }
|
||||
*
|
||||
* Layout:
|
||||
* [Full grid] [Empty grid] ← left=initial, right=removed (empty)
|
||||
* [opt1] [opt2] [opt3] [opt4] ← grid options with value label
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import Blocks from '../Blocks.vue';
|
||||
|
||||
const props = defineProps({ exercise: { type: Object, required: true } });
|
||||
const emit = defineEmits(['complete']);
|
||||
|
||||
const initial = computed(() => props.exercise.initialQuantity ?? 5);
|
||||
const removed = computed(() => props.exercise.removedQuantity ?? 2);
|
||||
const image = computed(() => props.exercise.image ?? null);
|
||||
const options = computed(() => props.exercise.options ?? []);
|
||||
const correct = computed(() => props.exercise.correctAnswer);
|
||||
|
||||
const selected = ref(null);
|
||||
const result = ref(null);
|
||||
|
||||
const select = (id) => {
|
||||
if (result.value === 'correct') return;
|
||||
selected.value = id;
|
||||
result.value = null;
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (selected.value === null || result.value === 'correct') return;
|
||||
const opt = options.value.find(o => o.id === selected.value);
|
||||
const ok = opt?.value === correct.value;
|
||||
result.value = ok ? 'correct' : 'incorrect';
|
||||
if (ok) emit('complete', { correct: true });
|
||||
};
|
||||
|
||||
// ── Grid exercises (stable computed) ──
|
||||
|
||||
// Left grid: full initial quantity
|
||||
const leftExercise = computed(() => {
|
||||
const v = Math.max(1, initial.value);
|
||||
const cols = v <= 3 ? v : Math.ceil(Math.sqrt(v));
|
||||
const rows = Math.ceil(v / cols);
|
||||
return {
|
||||
exerciseType: 'input-check',
|
||||
hideBorders: false,
|
||||
showCellBorders: true,
|
||||
answerPosition: 'right',
|
||||
answerSlot: { expects: -1 },
|
||||
_gridCols: cols,
|
||||
_gridRows: rows,
|
||||
grids: [{ id: 'g0', mode: 'static', value: v, label: '', image: image.value }],
|
||||
};
|
||||
});
|
||||
|
||||
// Right grid: shape = removed quantity (1 row × removed cols)
|
||||
// Empty — shows the space left by removed items
|
||||
const rightExercise = computed(() => {
|
||||
const v = Math.max(1, removed.value);
|
||||
return {
|
||||
exerciseType: 'input-check',
|
||||
hideBorders: false,
|
||||
showCellBorders: true,
|
||||
answerPosition: 'right',
|
||||
answerSlot: { expects: -1 },
|
||||
_gridCols: v,
|
||||
_gridRows: 1,
|
||||
grids: [{ id: 'g0', mode: 'static', value: 0, label: '' }],
|
||||
};
|
||||
});
|
||||
|
||||
// Option grids: small visual grids for each answer choice
|
||||
const optionExercises = computed(() =>
|
||||
options.value.map(opt => {
|
||||
const v = Math.max(1, opt.value);
|
||||
const cols = v <= 3 ? v : Math.ceil(Math.sqrt(v));
|
||||
const rows = Math.ceil(v / cols);
|
||||
return {
|
||||
id: opt.id,
|
||||
value: opt.value,
|
||||
exercise: {
|
||||
exerciseType: 'input-check',
|
||||
hideBorders: false,
|
||||
showCellBorders: true,
|
||||
answerPosition: 'right',
|
||||
answerSlot: { expects: -1 },
|
||||
_gridCols: cols,
|
||||
_gridRows: rows,
|
||||
grids: [{ id: 'g0', mode: 'static', value: v, label: '', image: image.value }],
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sv-card">
|
||||
|
||||
<div v-if="exercise.text" class="sv-header">
|
||||
<p class="sv-question">{{ exercise.text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main grids row -->
|
||||
<div class="sv-grids">
|
||||
|
||||
<!-- Left: full grid -->
|
||||
<div class="sv-grid-wrap sv-grid-wrap--full">
|
||||
<Blocks :exercise="leftExercise" key="sv-left" />
|
||||
<div class="sv-grid-label">{{ initial }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="sv-arrow">→</div>
|
||||
|
||||
<!-- Right: empty grid (removed space) -->
|
||||
<div class="sv-grid-wrap sv-grid-wrap--empty">
|
||||
<Blocks :exercise="rightExercise" key="sv-right" />
|
||||
<div class="sv-grid-label sv-grid-label--removed">- {{ removed }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="sv-options">
|
||||
<div v-for="opt in optionExercises" :key="opt.id" :class="['sv-option', {
|
||||
'sv-option--selected': selected === opt.id,
|
||||
'sv-option--correct': result && selected === opt.id && opt.value === correct,
|
||||
'sv-option--incorrect': result && selected === opt.id && opt.value !== correct,
|
||||
}]" @click="select(opt.id)">
|
||||
<div class="sv-opt-grid-wrap">
|
||||
<Blocks :exercise="opt.exercise" :key="`sv-opt-${opt.id}`" />
|
||||
</div>
|
||||
<div class="sv-opt-label">{{ opt.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sv-footer">
|
||||
<button class="sv-confirm" :class="{
|
||||
'sv-confirm--correct': result === 'correct',
|
||||
'sv-confirm--incorrect': result === 'incorrect',
|
||||
}" @click="confirm" :disabled="selected === null || result === 'correct'">
|
||||
{{ result === 'correct' ? '✓ Correcto' : result === 'incorrect' ? '✗ Inténtalo' : 'Confirmar' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sv-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.sv-header {
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.sv-question {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Main grids ── */
|
||||
.sv-grids {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sv-grid-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Strip Blocks.vue chrome */
|
||||
.sv-grid-wrap :deep(.ex-card),
|
||||
.sv-opt-grid-wrap :deep(.ex-card) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.sv-grid-wrap :deep(.ex-footer),
|
||||
.sv-grid-wrap :deep(.ex-header),
|
||||
.sv-grid-wrap :deep(.input-answer-slot),
|
||||
.sv-grid-wrap :deep(.btn-reset),
|
||||
.sv-opt-grid-wrap :deep(.ex-footer),
|
||||
.sv-opt-grid-wrap :deep(.ex-header),
|
||||
.sv-opt-grid-wrap :deep(.input-answer-slot),
|
||||
.sv-opt-grid-wrap :deep(.btn-reset) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sv-grid-wrap :deep(.ex-body),
|
||||
.sv-opt-grid-wrap :deep(.ex-body) {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.sv-grid-wrap :deep(.zone-sources),
|
||||
.sv-opt-grid-wrap :deep(.zone-sources) {
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.sv-grid-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.sv-grid-label--removed {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Empty grid gets a dashed border and light background */
|
||||
.sv-grid-wrap--empty :deep(.inline-grid) {
|
||||
background: #fef2f2 !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sv-arrow {
|
||||
font-size: 1.4rem;
|
||||
color: #9ca3af;
|
||||
align-self: center;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── Options ── */
|
||||
.sv-options {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sv-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #e5e7eb;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
user-select: none;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.sv-option:hover {
|
||||
border-color: #a5b4fc;
|
||||
background: #fafbff;
|
||||
}
|
||||
|
||||
.sv-option--selected {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.sv-option--correct {
|
||||
border-color: #10b981;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.sv-option--incorrect {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.sv-opt-label {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.sv-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.sv-confirm {
|
||||
padding: 0.45rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid #6366f1;
|
||||
background: white;
|
||||
color: #6366f1;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sv-confirm:hover:not(:disabled) {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sv-confirm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sv-confirm--correct {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sv-confirm--incorrect {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
386
blocksystem/src/components/exercise-types/Wheremore.vue
Normal file
386
blocksystem/src/components/exercise-types/Wheremore.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<script setup>
|
||||
/**
|
||||
* WhereMore — "¿Dónde hay más? / ¿Dónde hay menos?"
|
||||
*
|
||||
* Schema:
|
||||
* {
|
||||
* exerciseType: 'where-more',
|
||||
* text: '¿Dónde hay más? Marca con una X.',
|
||||
* mode: 'more' | 'less',
|
||||
* comparisons: [
|
||||
* { left: { value: 6, image: 'manzana' }, right: { value: 5, image: 'banana' } },
|
||||
* { left: { value: 9, image: 'naranja' }, right: { value: 7, image: 'frutilla' } },
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Each comparison is a row with two cards side by side.
|
||||
* The student marks a checkbox below each card.
|
||||
* A single Confirmar button checks all at once.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import Blocks from '../Blocks.vue';
|
||||
|
||||
const props = defineProps({
|
||||
exercise: { type: Object, required: true },
|
||||
});
|
||||
const emit = defineEmits(['complete']);
|
||||
|
||||
const mode = computed(() => props.exercise.mode ?? 'more');
|
||||
const comparisons = computed(() => props.exercise.comparisons ?? []);
|
||||
|
||||
// Per-row selection: which side is marked ('left' | 'right' | null)
|
||||
const selections = ref(comparisons.value.map(() => null));
|
||||
const result = ref(null); // null | 'correct' | 'incorrect'
|
||||
|
||||
// Per-row correct answer
|
||||
const correctSide = (comp) => {
|
||||
if (mode.value === 'more') return comp.left.value > comp.right.value ? 'left' : 'right';
|
||||
return comp.left.value < comp.right.value ? 'left' : 'right';
|
||||
};
|
||||
|
||||
const select = (rowIdx, side) => {
|
||||
if (result.value === 'correct') return;
|
||||
selections.value[rowIdx] = selections.value[rowIdx] === side ? null : side;
|
||||
result.value = null;
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (result.value === 'correct') return;
|
||||
const allCorrect = comparisons.value.every((comp, i) =>
|
||||
selections.value[i] === correctSide(comp)
|
||||
);
|
||||
result.value = allCorrect ? 'correct' : 'incorrect';
|
||||
if (allCorrect) emit('complete', { correct: true });
|
||||
};
|
||||
|
||||
// Pre-build all grid exercises as stable objects so Blocks.vue watch
|
||||
// doesn't trigger createInitialBlocks on every selection change.
|
||||
const gridExercises = computed(() =>
|
||||
comparisons.value.map(comp => {
|
||||
// Use the larger value's shape for both grids so they're the same size.
|
||||
// Shape: n<=3 → single row; otherwise ceil(sqrt(n)) cols × ceil(n/cols) rows
|
||||
// giving near-square grids: 4→2×2, 5-6→3×2, 7-9→3×3, 10-12→4×3
|
||||
const maxVal = Math.max(comp.left.value, comp.right.value);
|
||||
const cols = maxVal <= 3 ? maxVal : Math.ceil(Math.sqrt(maxVal));
|
||||
const rows = maxVal <= 3 ? 1 : Math.ceil(maxVal / cols);
|
||||
return {
|
||||
left: buildGridEx(comp.left.value, comp.left.image, cols, rows),
|
||||
right: buildGridEx(comp.right.value, comp.right.image, cols, rows),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
function buildGridEx(value, image, cols, rows) {
|
||||
const v = Math.max(1, value);
|
||||
return {
|
||||
exerciseType: 'input-check',
|
||||
hideBorders: false,
|
||||
showCellBorders: true,
|
||||
answerPosition: 'right',
|
||||
answerSlot: { expects: -1 },
|
||||
_gridCols: cols,
|
||||
_gridRows: rows,
|
||||
grids: [{ id: 'g0', mode: 'static', value: v, label: '', image: image ?? null }],
|
||||
};
|
||||
}
|
||||
|
||||
// Per-row, per-side state classes
|
||||
const sideClass = (rowIdx, side) => {
|
||||
const sel = selections.value[rowIdx] === side;
|
||||
if (!result.value) return sel ? 'wm-card--selected' : '';
|
||||
const correct = side === correctSide(comparisons.value[rowIdx]);
|
||||
if (sel && correct) return 'wm-card--correct';
|
||||
if (sel && !correct) return 'wm-card--incorrect';
|
||||
if (!sel && correct && result.value === 'incorrect') return 'wm-card--missed';
|
||||
return '';
|
||||
};
|
||||
|
||||
const checkboxClass = (rowIdx, side) => {
|
||||
const sel = selections.value[rowIdx] === side;
|
||||
if (!result.value) return sel ? 'wm-checkbox--checked' : '';
|
||||
const correct = side === correctSide(comparisons.value[rowIdx]);
|
||||
if (sel && correct) return 'wm-checkbox--correct';
|
||||
if (sel && !correct) return 'wm-checkbox--incorrect';
|
||||
return '';
|
||||
};
|
||||
|
||||
const checkboxSymbol = (rowIdx, side) => {
|
||||
const sel = selections.value[rowIdx] === side;
|
||||
if (!result.value) return sel ? '✓' : '';
|
||||
const correct = side === correctSide(comparisons.value[rowIdx]);
|
||||
if (sel && correct) return '✓';
|
||||
if (sel && !correct) return '✕';
|
||||
return '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wm-card">
|
||||
|
||||
<div v-if="exercise.text" class="wm-header">
|
||||
<p class="wm-question">{{ exercise.text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Pairs container so :last-child works correctly -->
|
||||
<div class="wm-pairs">
|
||||
<div v-for="(comp, rowIdx) in comparisons" :key="rowIdx" class="wm-pair">
|
||||
<div class="wm-pair-label">{{ rowIdx + 1 }}</div>
|
||||
<div class="wm-row">
|
||||
|
||||
<!-- LEFT card -->
|
||||
<div :class="['wm-side', sideClass(rowIdx, 'left')]" @click="select(rowIdx, 'left')">
|
||||
<div class="wm-grid-wrap">
|
||||
<Blocks :exercise="gridExercises[rowIdx].left" :key="`wm-l-${rowIdx}-${comp.left.value}`" />
|
||||
</div>
|
||||
<div class="wm-checkbox-row">
|
||||
<div :class="['wm-checkbox', checkboxClass(rowIdx, 'left')]">
|
||||
{{ checkboxSymbol(rowIdx, 'left') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT card -->
|
||||
<div :class="['wm-side', sideClass(rowIdx, 'right')]" @click="select(rowIdx, 'right')">
|
||||
<div class="wm-grid-wrap">
|
||||
<Blocks :exercise="gridExercises[rowIdx].right" :key="`wm-r-${rowIdx}-${comp.right.value}`" />
|
||||
</div>
|
||||
<div class="wm-checkbox-row">
|
||||
<div :class="['wm-checkbox', checkboxClass(rowIdx, 'right')]">
|
||||
{{ checkboxSymbol(rowIdx, 'right') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="wm-footer">
|
||||
<button class="wm-confirm" :class="{
|
||||
'wm-confirm--correct': result === 'correct',
|
||||
'wm-confirm--incorrect': result === 'incorrect',
|
||||
}" @click="confirm" :disabled="result === 'correct'">
|
||||
{{ result === 'correct' ? '✓ Correcto' : result === 'incorrect' ? '✗ Inténtalo' : 'Confirmar' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wm-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 16px 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.wm-header {
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wm-question {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Pair wrapper ── */
|
||||
.wm-pair {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 4px;
|
||||
border-bottom: 3px dashed #475569;
|
||||
}
|
||||
|
||||
.wm-pairs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wm-pair:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.wm-pair-label {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Each comparison row: two cards side by side ── */
|
||||
.wm-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Each side card ── */
|
||||
.wm-side {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wm-side:hover {
|
||||
border-color: #a5b4fc;
|
||||
background: #fafbff;
|
||||
}
|
||||
|
||||
.wm-card--selected {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.wm-card--correct {
|
||||
border-color: #10b981;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.wm-card--incorrect {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.wm-card--missed {
|
||||
border-color: #10b981;
|
||||
background: #f0fdf4;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Strip Blocks.vue card chrome */
|
||||
.wm-grid-wrap :deep(.ex-card) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.wm-grid-wrap :deep(.ex-footer),
|
||||
.wm-grid-wrap :deep(.ex-header),
|
||||
.wm-grid-wrap :deep(.input-answer-slot),
|
||||
.wm-grid-wrap :deep(.btn-reset) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wm-grid-wrap :deep(.ex-body) {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.wm-grid-wrap :deep(.zone-sources) {
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* ── Checkbox ── */
|
||||
.wm-checkbox-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wm-checkbox {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #d1d5db;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.wm-checkbox--checked {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.wm-checkbox--correct {
|
||||
border-color: #10b981;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wm-checkbox--incorrect {
|
||||
border-color: #ef4444;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.wm-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 4px 10px;
|
||||
}
|
||||
|
||||
|
||||
.wm-confirm {
|
||||
padding: 0.45rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid #6366f1;
|
||||
background: white;
|
||||
color: #6366f1;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.wm-confirm:hover:not(:disabled) {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wm-confirm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wm-confirm--correct {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wm-confirm--incorrect {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user