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"
|
<div v-for="(ex, i) in draft" :key="i" class="draft-item"
|
||||||
:class="{ 'draft-item--pinned': pinnedPreview === ex }"
|
:class="{ 'draft-item--pinned': pinnedPreview === ex }"
|
||||||
@click="pinnedPreview = pinnedPreview === ex ? null : 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-nums">{{ex.grids.filter(g => g.mode !== 'target').map(g => g.label ||
|
||||||
<span class="draft-dropzones">→ {{ ex.grids.filter(g => g.mode === 'target').map(g => g.label).join(', ') || 'sin banca' }}</span>
|
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"
|
<button class="btn-icon btn-delete"
|
||||||
@click.stop="draft.splice(i, 1); if (pinnedPreview === ex) pinnedPreview = null">✕</button>
|
@click.stop="draft.splice(i, 1); if (pinnedPreview === ex) pinnedPreview = null">✕</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,23 +81,29 @@
|
|||||||
@click="form.exerciseType = 'input-check'">Escribir número</button>
|
@click="form.exerciseType = 'input-check'">Escribir número</button>
|
||||||
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'sides' }"
|
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'sides' }"
|
||||||
@click="form.exerciseType = 'sides'">Lados ↔</button>
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Layout -->
|
<!-- 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>
|
<label>Disposición</label>
|
||||||
<div class="mode-toggle">
|
<div class="mode-toggle">
|
||||||
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row' }"
|
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn"
|
||||||
@click="form.layout = 'row'">Fila →</button>
|
: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' }"
|
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn"
|
||||||
@click="form.layout = 'column'">Columna ↓</button>
|
:class="{ 'mode-btn--active': form.layout === 'column' }" @click="form.layout = 'column'">Columna
|
||||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row-answer-right' }"
|
↓</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>
|
@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>
|
@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' }"
|
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
|
||||||
@click="form.layout = 'column'">Columna ↓</button>
|
:class="{ 'mode-btn--active': form.layout === 'column' }" @click="form.layout = 'column'">Columna
|
||||||
|
↓</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -138,8 +146,8 @@
|
|||||||
v-model="g.label" />
|
v-model="g.label" />
|
||||||
</div>
|
</div>
|
||||||
<!-- Fruit picker — dropdown select -->
|
<!-- Fruit picker — dropdown select -->
|
||||||
<div v-if="g.value > 0" class="fruit-select-wrap">
|
<div v-if="g.value > 0" class="image-select-wrap">
|
||||||
<select class="fruit-select" v-model="g.fruit">
|
<select class="image-select" v-model="g.image">
|
||||||
<option :value="null">🟥 Bloques de color</option>
|
<option :value="null">🟥 Bloques de color</option>
|
||||||
<option v-for="f in FRUITS" :key="f.key" :value="f.key">
|
<option v-for="f in FRUITS" :key="f.key" :value="f.key">
|
||||||
{{ f.emoji }} {{ f.label }}
|
{{ f.emoji }} {{ f.label }}
|
||||||
@@ -150,8 +158,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 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>
|
<label>Banca <span class="hint">— respuesta esperada por origen</span></label>
|
||||||
<div class="grid-row" style="margin-bottom:0.5rem">
|
<div class="grid-row" style="margin-bottom:0.5rem">
|
||||||
<input class="grid-label-input" type="text" placeholder="Etiqueta respuesta" v-model="form.bancaLabel"
|
<input class="grid-label-input" type="text" placeholder="Etiqueta respuesta" v-model="form.bancaLabel"
|
||||||
@@ -214,20 +233,19 @@ import Blocks from './components/Blocks.vue';
|
|||||||
const screen = ref(null);
|
const screen = ref(null);
|
||||||
|
|
||||||
// ── FRUIT CATALOGUE ──
|
// ── FRUIT CATALOGUE ──
|
||||||
// Keys must match what Blocks.vue / FRUIT_DICT accepts
|
// Keep in sync with IMAGE_DICT in Blocks.vue manually when adding/removing images.
|
||||||
// Keys must match FRUIT_DICT in Blocks.vue exactly
|
|
||||||
const FRUITS = [
|
const FRUITS = [
|
||||||
{ key: 'manzana', emoji: '🍎', label: 'Manzana' },
|
{ key: 'manzana', emoji: '🍎', label: 'Manzana' },
|
||||||
{ key: 'banana', emoji: '🍌', label: 'Banana' },
|
{ key: 'banana', emoji: '🍌', label: 'Banana' },
|
||||||
{ key: 'naranja', emoji: '🍊', label: 'Naranja' },
|
|
||||||
{ key: 'frutilla', emoji: '🍓', label: 'Frutilla' },
|
{ key: 'frutilla', emoji: '🍓', label: 'Frutilla' },
|
||||||
{ key: 'uva', emoji: '🍇', label: 'Uva' },
|
|
||||||
{ key: 'limon', emoji: '🍋', label: 'Limón' },
|
|
||||||
{ key: 'zanahoria', emoji: '🥕', label: 'Zanahoria' },
|
{ key: 'zanahoria', emoji: '🥕', label: 'Zanahoria' },
|
||||||
|
{ key: 'limon', emoji: '🍋', label: 'Limón' },
|
||||||
{ key: 'mango', emoji: '🥭', label: 'Mango' },
|
{ key: 'mango', emoji: '🥭', label: 'Mango' },
|
||||||
|
{ key: 'uva', emoji: '🍇', label: 'Uva' },
|
||||||
{ key: 'tomate', emoji: '🍅', label: 'Tomate' },
|
{ key: 'tomate', emoji: '🍅', label: 'Tomate' },
|
||||||
{ key: 'palta', emoji: '🥑', label: 'Palta' },
|
{ key: 'palta', emoji: '🥑', label: 'Palta' },
|
||||||
{ key: 'kiwi', emoji: '🥝', label: 'Kiwi' },
|
{ key: 'kiwi', emoji: '🥝', label: 'Kiwi' },
|
||||||
|
{ key: 'naranja', emoji: '🍊', label: 'Naranja' },
|
||||||
{ key: 'pina', emoji: '🍍', label: 'Piña' },
|
{ key: 'pina', emoji: '🍍', label: 'Piña' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -242,73 +260,224 @@ const FRUITS = [
|
|||||||
// answerSlot: { expects: number } (for input-check at exercise level)
|
// answerSlot: { expects: number } (for input-check at exercise level)
|
||||||
|
|
||||||
const demoExercises = ref([
|
const demoExercises = ref([
|
||||||
// ── move-blocks: operandos activos, banca target a la derecha ──
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// NEW EXERCISE COMPONENTS
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── compose-decompose ──
|
||||||
|
{
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 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',
|
exerciseType: 'move-blocks',
|
||||||
layout: 'row-answer-right',
|
layout: 'row-answer-right',
|
||||||
sidesAnswerLabel: '',
|
|
||||||
text: "María tiene 2 manzanas y Juan tiene 3. ¿Cuántas tienen en total?",
|
text: "María tiene 2 manzanas y Juan tiene 3. ¿Cuántas tienen en total?",
|
||||||
grids: [
|
grids: [
|
||||||
{ id: 'maria', mode: 'active', value: 2, label: 'María', fruit: 'manzana' },
|
{ id: 'maria', mode: 'active', value: 2, label: 'María', image: 'manzana' },
|
||||||
{ id: 'juan', mode: 'active', value: 3, label: 'Juan', fruit: 'zanahoria' },
|
{ id: 'juan', mode: 'active', value: 3, label: 'Juan', image: 'zanahoria' },
|
||||||
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 5 } },
|
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 5 } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── move-blocks: por origen específico, banca abajo ──
|
// ── move-blocks: answer bottom, by origin ──
|
||||||
{
|
{
|
||||||
exerciseType: 'move-blocks',
|
exerciseType: 'move-blocks',
|
||||||
layout: 'row-answer-bottom',
|
layout: 'row-answer-bottom',
|
||||||
text: "Pedro tiene 3 manzanas y Alicia tiene 2. Pedro le da 2 a Alicia. ¿Cuántas tiene Alicia?",
|
text: "Pedro tiene 3 manzanas y Alicia tiene 2. Pedro le da 2 a Alicia. ¿Cuántas tiene Alicia?",
|
||||||
grids: [
|
grids: [
|
||||||
{ id: 'pedro', mode: 'active', value: 3, label: 'Pedro', fruit: 'manzana' },
|
{ id: 'pedro', mode: 'active', value: 3, label: 'Pedro', image: 'manzana' },
|
||||||
{ id: 'alicia', mode: 'active', value: 2, label: 'Alicia', fruit: 'frutilla' },
|
{ 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',
|
id: 'resp', mode: 'target', label: 'Alicia al final',
|
||||||
layout: 'row',
|
answerSlot: { from: [{ id: 'pedro', count: 2 }, { id: 'alicia', count: 2 }] }
|
||||||
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 ──
|
// ── sides ──
|
||||||
{
|
|
||||||
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 ──
|
|
||||||
{
|
{
|
||||||
exerciseType: 'sides',
|
exerciseType: 'sides',
|
||||||
text: "Junta los bloques de ambos lados en el centro.",
|
text: "Junta los bloques de ambos lados en el centro.",
|
||||||
grids: [
|
grids: [
|
||||||
{ id: 'izq1', mode: 'active', value: 3, label: 'Grupo A', fruit: 'manzana' },
|
{ id: 'izq1', mode: 'active', value: 3, label: 'Grupo A', image: 'manzana' },
|
||||||
{ id: 'der1', mode: 'active', value: 2, label: 'Grupo B', fruit: 'banana' },
|
{ id: 'der1', mode: 'active', value: 2, label: 'Grupo B', image: 'banana' },
|
||||||
{ id: 'izq2', mode: 'active', value: 2, label: 'Grupo C', fruit: 'naranja' },
|
{ id: 'izq2', mode: 'active', value: 1, label: 'Grupo C', image: 'naranja' },
|
||||||
{ id: 'resp', mode: 'target', label: 'Total',
|
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 6 } },
|
||||||
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 ──
|
// ── USER EXERCISES ──
|
||||||
@@ -325,14 +494,14 @@ const growUserArrays = (exercises) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── COMPLETE ──
|
// ── COMPLETE ──
|
||||||
const onComplete = (_group, _idx, _result) => {};
|
const onComplete = (_group, _idx, _result) => { };
|
||||||
|
|
||||||
// ── RESET ──
|
// ── 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); };
|
const resetAllUser = () => { userKeys.value = userKeys.value.map(k => k + 1000); };
|
||||||
|
|
||||||
// ── CREATOR ──
|
// ── CREATOR ──
|
||||||
const defaultGrid = () => ({ value: '', label: '', needs: '', fruit: null });
|
const defaultGrid = () => ({ value: '', label: '', needs: '', image: null });
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
text: '',
|
text: '',
|
||||||
exerciseType: 'move-blocks',
|
exerciseType: 'move-blocks',
|
||||||
@@ -360,7 +529,8 @@ const canAdd = computed(() => {
|
|||||||
return form.value.expectedAnswer !== '' && !isNaN(Number(form.value.expectedAnswer));
|
return form.value.expectedAnswer !== '' && !isNaN(Number(form.value.expectedAnswer));
|
||||||
if (form.value.exerciseType === 'sides')
|
if (form.value.exerciseType === 'sides')
|
||||||
return validGrids.value.some(g => parseInt(g.needs) > 0);
|
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
|
// Grid inputs — cascade: fill → next slot appears
|
||||||
@@ -400,7 +570,7 @@ const previewExercise = computed(() => {
|
|||||||
mode: form.value.exerciseType === 'move-blocks' ? 'active' : 'static',
|
mode: form.value.exerciseType === 'move-blocks' ? 'active' : 'static',
|
||||||
value: parseInt(g.value),
|
value: parseInt(g.value),
|
||||||
label: g.label || String(g.value),
|
label: g.label || String(g.value),
|
||||||
...(g.fruit ? { fruit: g.fruit } : {}),
|
...(g.image ? { image: g.image } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (form.value.exerciseType === 'move-blocks') {
|
if (form.value.exerciseType === 'move-blocks') {
|
||||||
@@ -465,9 +635,10 @@ const previewExercise = computed(() => {
|
|||||||
// mark-options
|
// mark-options
|
||||||
return {
|
return {
|
||||||
exerciseType: 'mark-options',
|
exerciseType: 'mark-options',
|
||||||
|
hideBorders: form.value.hideBorders,
|
||||||
layout: 'row',
|
layout: 'row',
|
||||||
text: form.value.text,
|
text: form.value.text,
|
||||||
answerSlot: { correctId: srcGrids[0]?.id },
|
answerSlot: { correctId: srcGrids[form.value.correctGridIdx]?.id ?? srcGrids[0]?.id },
|
||||||
grids: srcGrids,
|
grids: srcGrids,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -691,8 +862,14 @@ const loadDraft = () => {
|
|||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.needs-arrow { color: #9ca3af; }
|
.needs-arrow {
|
||||||
.needs-hint { color: #9ca3af; font-size: 0.75rem; }
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.needs-hint {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-num-input {
|
.grid-num-input {
|
||||||
width: 58px;
|
width: 58px;
|
||||||
@@ -711,7 +888,9 @@ const loadDraft = () => {
|
|||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-num-input--sm { width: 46px; }
|
.grid-num-input--sm {
|
||||||
|
width: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-label-input {
|
.grid-label-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -796,8 +975,14 @@ button {
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover { background: #f0f0f0; }
|
button:hover {
|
||||||
button:disabled { opacity: 0.5; cursor: default; }
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
padding: 0.2rem 0.45rem;
|
padding: 0.2rem 0.45rem;
|
||||||
@@ -806,7 +991,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add-draft { align-self: flex-start; }
|
.btn-add-draft {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-load {
|
.btn-load {
|
||||||
background: #6366f1;
|
background: #6366f1;
|
||||||
@@ -814,7 +1001,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||||||
border-color: #6366f1;
|
border-color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-load:hover { background: #4f46e5; }
|
.btn-load:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
.mode-toggle {
|
.mode-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -836,9 +1025,18 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-btn:hover { background: #f0f0f0; }
|
.mode-btn:hover {
|
||||||
.mode-btn--active { background: #6366f1; color: white; }
|
background: #f0f0f0;
|
||||||
.mode-btn--active:hover { background: #4f46e5; }
|
}
|
||||||
|
|
||||||
|
.mode-btn--active {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn--active:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -864,18 +1062,34 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-item:last-child { border-bottom: none; }
|
.draft-item:last-child {
|
||||||
.draft-item:hover { background: #fafbff; }
|
border-bottom: none;
|
||||||
.draft-item--pinned { background: #eef2ff; }
|
}
|
||||||
.draft-nums { font-weight: 600; color: #374151; }
|
|
||||||
.draft-dropzones { color: #9ca3af; font-size: 0.78rem; }
|
.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 picker (select) ── */
|
||||||
.fruit-select-wrap {
|
.image-select-wrap {
|
||||||
padding: 2px 0 4px 26px;
|
padding: 2px 0 4px 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fruit-select {
|
.image-select {
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -888,9 +1102,8 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fruit-select:focus {
|
.image-select:focus {
|
||||||
outline: 2px solid #6366f1;
|
outline: 2px solid #6366f1;
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted, defineAsyncComponent } from "vue";
|
||||||
|
|
||||||
|
// ===== EXERCISE TYPE REGISTRY =====
|
||||||
|
// To add a new exercise type: create the .vue file and add one line here.
|
||||||
|
const LoadingEl = { template: '<div style="padding:12px;color:#94a3b8;font-size:0.85rem">Cargando...</div>' };
|
||||||
|
const ErrorEl = { template: '<div style="padding:12px;color:#ef4444;font-size:0.85rem">Error al cargar ejercicio.</div>' };
|
||||||
|
|
||||||
|
const EXERCISE_REGISTRY = {
|
||||||
|
'compose-decompose': defineAsyncComponent({ loader: () => import('./exercise-types/ComposeDecompose.vue'), loadingComponent: LoadingEl, errorComponent: ErrorEl }),
|
||||||
|
'picture-choose': defineAsyncComponent({ loader: () => import('./exercise-types/PictureChoose.vue'), loadingComponent: LoadingEl, errorComponent: ErrorEl }),
|
||||||
|
'where-more': defineAsyncComponent({ loader: () => import('./exercise-types/WhereMore.vue'), loadingComponent: LoadingEl, errorComponent: ErrorEl }),
|
||||||
|
'connect': defineAsyncComponent({ loader: () => import('./exercise-types/ConnectLines.vue'), loadingComponent: LoadingEl, errorComponent: ErrorEl }),
|
||||||
|
'subtract-visual': defineAsyncComponent({ loader: () => import('./exercise-types/SubtractVisual.vue'), loadingComponent: LoadingEl, errorComponent: ErrorEl }),
|
||||||
|
};
|
||||||
|
const externalComponent = computed(() => EXERCISE_REGISTRY[props.exercise?.exerciseType] ?? null);
|
||||||
|
|
||||||
// ===== PROPS =====
|
// ===== PROPS =====
|
||||||
// New schema:
|
// New schema:
|
||||||
@@ -7,7 +21,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
|||||||
// exercise.layout : 'row' | 'column' | 'row-answer-right' | 'row-answer-bottom'
|
// exercise.layout : 'row' | 'column' | 'row-answer-right' | 'row-answer-bottom'
|
||||||
// exercise.answerPosition (input-check): 'right' | 'bottom'
|
// exercise.answerPosition (input-check): 'right' | 'bottom'
|
||||||
// exercise.answerSlot : { expects } (input-check) | { correctId } (mark-options)
|
// exercise.answerSlot : { expects } (input-check) | { correctId } (mark-options)
|
||||||
// exercise.grids[] : { id, mode:'active'|'static'|'target', value, label, fruit }
|
// exercise.grids[] : { id, mode:'active'|'static'|'target', value, label, image }
|
||||||
// target grids also have: answerSlot: { from:'any'|[{id,count}], count }
|
// target grids also have: answerSlot: { from:'any'|[{id,count}], count }
|
||||||
// @complete="({ correct }) => ..."
|
// @complete="({ correct }) => ..."
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -28,7 +42,7 @@ const _gridShape = (n) => {
|
|||||||
// ===== CONFIG =====
|
// ===== CONFIG =====
|
||||||
// Translates the new exercise schema into the internal gridDefs format.
|
// Translates the new exercise schema into the internal gridDefs format.
|
||||||
// Internal gridDef fields:
|
// Internal gridDef fields:
|
||||||
// id, label, cols, rows, initialBlocks, mode, sourceId, fruit
|
// id, label, cols, rows, initialBlocks, mode, sourceId, image
|
||||||
// isAnswer (bool), noSnap (bool), showLabel (bool)
|
// isAnswer (bool), noSnap (bool), showLabel (bool)
|
||||||
// _answerSlot (raw answerSlot from JSON, only on target grids)
|
// _answerSlot (raw answerSlot from JSON, only on target grids)
|
||||||
const cfg = computed(() => {
|
const cfg = computed(() => {
|
||||||
@@ -55,10 +69,16 @@ const cfg = computed(() => {
|
|||||||
cols = Math.max(total + 1, 3);
|
cols = Math.max(total + 1, 3);
|
||||||
rows = 3;
|
rows = 3;
|
||||||
initialBlocks = [];
|
initialBlocks = [];
|
||||||
|
} else {
|
||||||
|
// Allow explicit cols/rows override from parent (e.g. ComposeDecompose forces 1 row)
|
||||||
|
if (ex._gridCols != null && ex._gridRows != null) {
|
||||||
|
cols = ex._gridCols;
|
||||||
|
rows = ex._gridRows;
|
||||||
} else {
|
} else {
|
||||||
const shape = _gridShape(g.value ?? 1);
|
const shape = _gridShape(g.value ?? 1);
|
||||||
cols = shape.cols;
|
cols = shape.cols;
|
||||||
rows = shape.rows;
|
rows = shape.rows;
|
||||||
|
}
|
||||||
initialBlocks = [g.value ?? 1];
|
initialBlocks = [g.value ?? 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +96,7 @@ const cfg = computed(() => {
|
|||||||
// Interaction: active grids allow dragging; static and target don't initiate drag.
|
// Interaction: active grids allow dragging; static and target don't initiate drag.
|
||||||
interactive: mode === 'active',
|
interactive: mode === 'active',
|
||||||
showLabel: isTarget,
|
showLabel: isTarget,
|
||||||
fruit: g.fruit ?? null,
|
image: g.image ?? null,
|
||||||
_answerSlot: isTarget ? (g.answerSlot ?? null) : null,
|
_answerSlot: isTarget ? (g.answerSlot ?? null) : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -109,6 +129,10 @@ const cfg = computed(() => {
|
|||||||
answerPosition: ex.answerPosition ?? 'right',
|
answerPosition: ex.answerPosition ?? 'right',
|
||||||
showGridLabels: true,
|
showGridLabels: true,
|
||||||
hideBorders: ex.hideBorders ?? false,
|
hideBorders: ex.hideBorders ?? false,
|
||||||
|
showCellBorders: ex.showCellBorders ?? true,
|
||||||
|
gridPadding: ex.gridPadding ?? 0,
|
||||||
|
blockColor: ex._blockColor ?? null, // override block color
|
||||||
|
cellSizeOverride: ex._cellSize ?? null, // force uniform cell size
|
||||||
grids: gridDefs,
|
grids: gridDefs,
|
||||||
_targetAnswers,
|
_targetAnswers,
|
||||||
// input-check / mark-options store their answer at exercise level
|
// input-check / mark-options store their answer at exercise level
|
||||||
@@ -118,14 +142,14 @@ const cfg = computed(() => {
|
|||||||
// ===== FRUIT IMAGES =====
|
// ===== FRUIT IMAGES =====
|
||||||
const BASE = 'https://cdn.jsdelivr.net/gh/fruits-360/fruits-360-100x100@main/Training/';
|
const BASE = 'https://cdn.jsdelivr.net/gh/fruits-360/fruits-360-100x100@main/Training/';
|
||||||
|
|
||||||
const FRUIT_DICT = {
|
const IMAGE_DICT = {
|
||||||
manzana: { url: BASE + 'Apple%20Red%201/102_100.jpg' },
|
manzana: { url: BASE + 'Apple%20Red%201/102_100.jpg' },
|
||||||
banana: { url: BASE + 'Banana%201/55_100.jpg' },
|
banana: { url: BASE + 'Banana%201/55_100.jpg' },
|
||||||
frutilla: { url: BASE + 'Strawberry%201/0_100.jpg', rotate: true },
|
frutilla: { url: BASE + 'Strawberry%201/0_100.jpg', rotate: true },
|
||||||
zanahoria: { url: BASE + 'Carrot%201/r0_0_100.jpg', rotate: true },
|
zanahoria: { url: BASE + 'Carrot%201/r0_0_100.jpg', rotate: true },
|
||||||
limon: { url: BASE + 'Lemon%201/0_100.jpg' },
|
limon: { url: BASE + 'Lemon%201/0_100.jpg' },
|
||||||
mango: { url: BASE + 'Mango%201/0_100.jpg' },
|
mango: { url: BASE + 'Mango%201/0_100.jpg' },
|
||||||
uva: { url: BASE + 'Grape%20Blue/0_100.jpg' },
|
uva: { url: BASE + 'Grape%20Blue%201/0_100.jpg' },
|
||||||
tomate: { url: BASE + 'Tomato%201/0_100.jpg' },
|
tomate: { url: BASE + 'Tomato%201/0_100.jpg' },
|
||||||
palta: { url: BASE + 'Avocado%201/0_100.jpg' },
|
palta: { url: BASE + 'Avocado%201/0_100.jpg' },
|
||||||
kiwi: { url: BASE + 'Kiwi%201/0_100.jpg' },
|
kiwi: { url: BASE + 'Kiwi%201/0_100.jpg' },
|
||||||
@@ -133,8 +157,8 @@ const FRUIT_DICT = {
|
|||||||
pina: { url: BASE + 'Pineapple%201/0_100.jpg' },
|
pina: { url: BASE + 'Pineapple%201/0_100.jpg' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFruitUrl = (name) => name ? (FRUIT_DICT[name]?.url ?? null) : null;
|
const getFruitUrl = (name) => name ? (IMAGE_DICT[name]?.url ?? null) : null;
|
||||||
const getFruitRotate = (name) => name ? (FRUIT_DICT[name]?.rotate ?? false) : false;
|
const getBlockRotate = (name) => name ? (IMAGE_DICT[name]?.rotate ?? false) : false;
|
||||||
|
|
||||||
// ===== GRID DEFINITIONS =====
|
// ===== GRID DEFINITIONS =====
|
||||||
const buildGridDefs = (rawGrids) =>
|
const buildGridDefs = (rawGrids) =>
|
||||||
@@ -150,7 +174,7 @@ const buildGridDefs = (rawGrids) =>
|
|||||||
interactive: g.interactive ?? true,
|
interactive: g.interactive ?? true,
|
||||||
showLabel: g.showLabel ?? true,
|
showLabel: g.showLabel ?? true,
|
||||||
noSnap: g.noSnap ?? false,
|
noSnap: g.noSnap ?? false,
|
||||||
fruit: g.fruit ?? null,
|
image: g.image ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// gridDefs must stay as ref (not computed) because blocks reference gridDefs by id.
|
// gridDefs must stay as ref (not computed) because blocks reference gridDefs by id.
|
||||||
@@ -249,10 +273,13 @@ const containerRef = ref(null);
|
|||||||
const getGridCols = (gridId) => gridDefs.value.find(g => g.id === gridId)?.cols ?? 8;
|
const getGridCols = (gridId) => gridDefs.value.find(g => g.id === gridId)?.cols ?? 8;
|
||||||
const getGridRows = (gridId) => gridDefs.value.find(g => g.id === gridId)?.rows ?? 7;
|
const getGridRows = (gridId) => gridDefs.value.find(g => g.id === gridId)?.rows ?? 7;
|
||||||
|
|
||||||
// Cell size: uniform across all grids, sized to fit the widest one.
|
// Cell size: use explicit _cellSize if provided (for uniform sizing across grids),
|
||||||
const getCellSize = (_gridId) => {
|
// otherwise calculate from the grid's own cols.
|
||||||
const maxCols = Math.max(...gridDefs.value.map(g => g.cols));
|
const getCellSize = (gridId) => {
|
||||||
return Math.max(20, Math.min(36, Math.floor(330 / maxCols)));
|
if (cfg.value.cellSizeOverride) return cfg.value.cellSizeOverride;
|
||||||
|
const g = gridDefs.value.find(d => d.id === gridId);
|
||||||
|
if (!g) return 28;
|
||||||
|
return Math.max(20, Math.min(48, Math.floor(180 / g.cols)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGridWidth = (gridId) => getGridCols(gridId) * getCellSize(gridId);
|
const getGridWidth = (gridId) => getGridCols(gridId) * getCellSize(gridId);
|
||||||
@@ -266,9 +293,9 @@ const onResize = () => { borderStyleCache.clear(); };
|
|||||||
|
|
||||||
// ===== BLOCK FACTORY =====
|
// ===== BLOCK FACTORY =====
|
||||||
// All blocks are 1×1 units.
|
// All blocks are 1×1 units.
|
||||||
const createBlock = (gridX, gridY, id, gridId = 0, originGridId = gridId) => {
|
const createBlock = (gridX, gridY, id, gridId = 0, originGridId = gridId, colorOverride = null) => {
|
||||||
const originGrid = gridDefs.value.find(g => g.id === originGridId);
|
const originGrid = gridDefs.value.find(g => g.id === originGridId);
|
||||||
const fruitName = originGrid?.fruit ?? null;
|
const imageName = originGrid?.image ?? null;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type: "uno",
|
type: "uno",
|
||||||
@@ -277,13 +304,13 @@ const createBlock = (gridX, gridY, id, gridId = 0, originGridId = gridId) => {
|
|||||||
gridX,
|
gridX,
|
||||||
gridY,
|
gridY,
|
||||||
value: 1,
|
value: 1,
|
||||||
color: BLOCK_COLOR,
|
color: colorOverride ?? BLOCK_COLOR,
|
||||||
fruitImage: getFruitUrl(fruitName),
|
blockImage: getFruitUrl(imageName),
|
||||||
fruitRotate: getFruitRotate(fruitName),
|
blockRotate: getBlockRotate(imageName),
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
units: [{ relX: 0, relY: 0, color: BLOCK_COLOR, baseColor: BLOCK_COLOR }],
|
units: [{ relX: 0, relY: 0, color: colorOverride ?? BLOCK_COLOR, baseColor: colorOverride ?? BLOCK_COLOR }],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1304,21 +1331,17 @@ const createInitialBlocks = () => {
|
|||||||
|
|
||||||
const placeGroupInQuadrant = (count, q) => {
|
const placeGroupInQuadrant = (count, q) => {
|
||||||
const occupied = new Set();
|
const occupied = new Set();
|
||||||
|
// Always fill left→right, top→bottom — no random shuffle
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (let y = q.y0; y < q.y1; y++)
|
for (let y = q.y0; y < q.y1; y++)
|
||||||
for (let x = q.x0; x < q.x1; x++) candidates.push({ x, y });
|
for (let x = q.x0; x < q.x1; x++) candidates.push({ x, y });
|
||||||
|
|
||||||
for (let i = candidates.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
let placed = 0;
|
let placed = 0;
|
||||||
for (const { x, y } of candidates) {
|
for (const { x, y } of candidates) {
|
||||||
if (placed >= count) break;
|
if (placed >= count) break;
|
||||||
if (occupied.has(`${x},${y}`)) continue;
|
if (occupied.has(`${x},${y}`)) continue;
|
||||||
if (!isPositionAvailable(gId, x, y)) continue;
|
if (!isPositionAvailable(gId, x, y)) continue;
|
||||||
blocks.value.push(createBlock(x, y, nextId++, gId));
|
blocks.value.push(createBlock(x, y, nextId++, gId, gId, cfg.value.blockColor ?? null));
|
||||||
occupied.add(`${x},${y}`);
|
occupied.add(`${x},${y}`);
|
||||||
placed++;
|
placed++;
|
||||||
}
|
}
|
||||||
@@ -1527,7 +1550,12 @@ const getGroupOutlines = (gridId) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ex-card" :class="{ 'ex-card--ok': cardResult === 'ok', 'ex-card--bad': cardResult === 'bad' }">
|
<!-- External exercise type from registry -->
|
||||||
|
<component v-if="externalComponent" :is="externalComponent" :exercise="exercise"
|
||||||
|
@complete="emit('complete', $event)" />
|
||||||
|
|
||||||
|
<!-- Legacy types handled internally -->
|
||||||
|
<div v-else class="ex-card" :class="{ 'ex-card--ok': cardResult === 'ok', 'ex-card--bad': cardResult === 'bad' }">
|
||||||
|
|
||||||
<!-- Card header -->
|
<!-- Card header -->
|
||||||
<div v-if="exercise?.text || cardResult" class="ex-header">
|
<div v-if="exercise?.text || cardResult" class="ex-header">
|
||||||
@@ -1540,23 +1568,23 @@ const getGroupOutlines = (gridId) => {
|
|||||||
<!-- MOVE-BLOCKS -->
|
<!-- MOVE-BLOCKS -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<template v-if="cfg.exType === 'move-blocks'">
|
<template v-if="cfg.exType === 'move-blocks'">
|
||||||
<div ref="containerRef"
|
<div ref="containerRef" :class="['ex-body', `layout-${cfg.layout}`]" @mousedown="handleGridCanvasClick">
|
||||||
:class="['ex-body', `layout-${cfg.layout}`]"
|
|
||||||
@mousedown="handleGridCanvasClick">
|
|
||||||
|
|
||||||
<!-- Source grids (active) -->
|
<!-- Source grids (active) -->
|
||||||
<div :class="['zone-sources', cfg.layout === 'column' ? 'zone-sources--col' : 'zone-sources--row']">
|
<div :class="['zone-sources', cfg.layout === 'column' ? 'zone-sources--col' : 'zone-sources--row']">
|
||||||
<div v-for="gridDef in gridDefs.filter(g => !g.isAnswer)" :key="gridDef.id"
|
<div v-for="gridDef in gridDefs.filter(g => !g.isAnswer)" :key="gridDef.id" class="grid-wrapper">
|
||||||
class="grid-wrapper" style="position:relative">
|
|
||||||
|
|
||||||
<!-- Grid box -->
|
<!-- Grid box -->
|
||||||
<div :class="['grid-center', 'inline-grid']" :data-grid-index="gridDef.id"
|
<div :class="['grid-center', 'inline-grid']" :data-grid-index="gridDef.id"
|
||||||
|
:style="cfg.gridPadding ? { padding: cfg.gridPadding + 'px', boxSizing: 'content-box' } : {}"
|
||||||
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
||||||
<div class="grid-body grid-body--no-titlebar">
|
<div class="grid-body grid-body--no-titlebar">
|
||||||
<div class="grid-transparent" :class="{ 'cutting-cursor': cuttingMode }"
|
<div
|
||||||
|
:class="['grid-transparent', { 'cutting-cursor': cuttingMode, 'grid--no-cell-borders': !cfg.showCellBorders }]"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
||||||
@mousedown="handleGridCanvasClick">
|
@mousedown="handleGridCanvasClick">
|
||||||
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`" class="grid-row-transparent">
|
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`"
|
||||||
|
class="grid-row-transparent">
|
||||||
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
||||||
class="grid-cell-transparent"
|
class="grid-cell-transparent"
|
||||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||||
@@ -1565,67 +1593,70 @@ const getGroupOutlines = (gridId) => {
|
|||||||
<div class="blocks-layer-transparent"
|
<div class="blocks-layer-transparent"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
||||||
@mousedown="handleBlockMouseDown($event, block.id, gridDef.id)"
|
@mousedown="handleBlockMouseDown($event, block.id, gridDef.id)" :class="['block-transparent',
|
||||||
:class="['block-transparent',
|
{
|
||||||
{ 'block-dragging': draggedGroupIds.has(block.id),
|
'block-dragging': draggedGroupIds.has(block.id),
|
||||||
'block-selected': selectedGroupIds.has(block.id) && !block.isConnected,
|
'block-selected': selectedGroupIds.has(block.id) && !block.isConnected,
|
||||||
'block-connected': block.isConnected,
|
'block-connected': block.isConnected,
|
||||||
'no-hover': cuttingMode,
|
'no-hover': cuttingMode,
|
||||||
'visually-separated': block.visuallySeparated }]"
|
'visually-separated': block.visuallySeparated
|
||||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px',
|
}]" :style="{
|
||||||
|
left: block.gridX * getCellSize(gridDef.id) + 'px',
|
||||||
top: block.gridY * getCellSize(gridDef.id) + 'px',
|
top: block.gridY * getCellSize(gridDef.id) + 'px',
|
||||||
width: getCellSize(gridDef.id) + 'px',
|
width: getCellSize(gridDef.id) + 'px',
|
||||||
height: getCellSize(gridDef.id) + 'px',
|
height: getCellSize(gridDef.id) + 'px',
|
||||||
position: 'absolute', backgroundColor: 'transparent' }">
|
position: 'absolute', backgroundColor: 'transparent'
|
||||||
|
}">
|
||||||
<template v-if="block.units?.length > 0">
|
<template v-if="block.units?.length > 0">
|
||||||
<div v-for="(unit, i) in block.units" :key="i"
|
<div v-for="(unit, i) in block.units" :key="i"
|
||||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||||
:style="block.fruitImage
|
:style="block.blockImage
|
||||||
? { position:'absolute', left:'0', top:'0', width:'100%', height:'100%', backgroundImage:`url(${block.fruitImage})`, backgroundSize:'contain', backgroundRepeat:'no-repeat', backgroundPosition:'center', backgroundColor:'transparent', mixBlendMode:'multiply', border:'none', boxSizing:'border-box', transform: block.fruitRotate?'rotate(180deg)':'none' }
|
? { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', backgroundImage: `url(${block.blockImage})`, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundColor: 'transparent', mixBlendMode: 'multiply', border: 'none', boxSizing: 'border-box', transform: block.blockRotate ? 'rotate(180deg)' : 'none' }
|
||||||
: { position:'absolute', left: unit.relX*getCellSize(gridDef.id)+'px', top: unit.relY*getCellSize(gridDef.id)+'px', width: getCellSize(gridDef.id)+'px', height: getCellSize(gridDef.id)+'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing:'border-box' }">
|
: { position: 'absolute', left: unit.relX * getCellSize(gridDef.id) + 'px', top: unit.relY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing: 'border-box' }">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!block.fruitImage"
|
<div v-if="!block.blockImage"
|
||||||
:style="{ position:'absolute', inset:0, backgroundImage: getBorderStyle(block), pointerEvents:'none' }"></div>
|
:style="{ position: 'absolute', inset: 0, backgroundImage: getBorderStyle(block), pointerEvents: 'none' }">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group outlines SVG -->
|
<!-- Group outlines SVG -->
|
||||||
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:10 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 10 }">
|
||||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5"
|
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||||
stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
class="group-outline-path" />
|
||||||
</svg>
|
</svg>
|
||||||
<!-- Dashed border (active grid) -->
|
<!-- Dashed border (active grid) -->
|
||||||
<svg v-if="!cfg.hideBorders" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg v-if="!cfg.hideBorders" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:20 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 20 }">
|
||||||
<rect x="1" y="1"
|
<rect x="1" y="1" :width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
||||||
:width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
|
||||||
fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="8 5" rx="10" ry="10" />
|
fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="8 5" rx="10" ry="10" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid-label-below">{{ gridDef.label }}</div>
|
<div v-if="gridDef.label" class="grid-label-below">{{ gridDef.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Arrow / spacer -->
|
<!-- Arrow / spacer -->
|
||||||
<div class="zone-arrow">→</div>
|
<div v-if="cfg.layout !== 'column'" class="zone-arrow">{{ cfg.layout === 'row-answer-bottom' ? '↓' : '→' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Target grids -->
|
<!-- Target grids -->
|
||||||
<div class="zone-targets">
|
<div class="zone-targets">
|
||||||
<div v-for="gridDef in gridDefs.filter(g => g.isAnswer)" :key="gridDef.id"
|
<div v-for="gridDef in gridDefs.filter(g => g.isAnswer)" :key="gridDef.id" class="grid-wrapper">
|
||||||
class="grid-wrapper" style="position:relative">
|
|
||||||
|
|
||||||
<div class="grid-center inline-grid" :data-grid-index="gridDef.id"
|
<div class="grid-center inline-grid" :data-grid-index="gridDef.id" @mousedown.stop
|
||||||
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
@mousedown.capture="handleCutModeMouseDown">
|
||||||
<div class="grid-body grid-body--no-titlebar">
|
<div class="grid-body grid-body--no-titlebar">
|
||||||
<div class="grid-transparent" :class="{ 'cutting-cursor': cuttingMode }"
|
<div
|
||||||
|
:class="['grid-transparent', { 'cutting-cursor': cuttingMode, 'grid--no-cell-borders': !cfg.showCellBorders }]"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
||||||
@mousedown="handleGridCanvasClick">
|
@mousedown="handleGridCanvasClick">
|
||||||
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`" class="grid-row-transparent">
|
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`"
|
||||||
|
class="grid-row-transparent">
|
||||||
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
||||||
class="grid-cell-transparent"
|
class="grid-cell-transparent"
|
||||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||||
@@ -1634,46 +1665,47 @@ const getGroupOutlines = (gridId) => {
|
|||||||
<div class="blocks-layer-transparent"
|
<div class="blocks-layer-transparent"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
||||||
@mousedown="handleBlockMouseDown($event, block.id, gridDef.id)"
|
@mousedown="handleBlockMouseDown($event, block.id, gridDef.id)" :class="['block-transparent',
|
||||||
:class="['block-transparent',
|
{
|
||||||
{ 'block-dragging': draggedGroupIds.has(block.id),
|
'block-dragging': draggedGroupIds.has(block.id),
|
||||||
'block-selected': selectedGroupIds.has(block.id) && !block.isConnected,
|
'block-selected': selectedGroupIds.has(block.id) && !block.isConnected,
|
||||||
'block-connected': block.isConnected,
|
'block-connected': block.isConnected,
|
||||||
'no-hover': cuttingMode }]"
|
'no-hover': cuttingMode
|
||||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px',
|
}]" :style="{
|
||||||
|
left: block.gridX * getCellSize(gridDef.id) + 'px',
|
||||||
top: block.gridY * getCellSize(gridDef.id) + 'px',
|
top: block.gridY * getCellSize(gridDef.id) + 'px',
|
||||||
width: getCellSize(gridDef.id) + 'px',
|
width: getCellSize(gridDef.id) + 'px',
|
||||||
height: getCellSize(gridDef.id) + 'px',
|
height: getCellSize(gridDef.id) + 'px',
|
||||||
position: 'absolute', backgroundColor: 'transparent' }">
|
position: 'absolute', backgroundColor: 'transparent'
|
||||||
|
}">
|
||||||
<template v-if="block.units?.length > 0">
|
<template v-if="block.units?.length > 0">
|
||||||
<div v-for="(unit, i) in block.units" :key="i"
|
<div v-for="(unit, i) in block.units" :key="i"
|
||||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||||
:style="block.fruitImage
|
:style="block.blockImage
|
||||||
? { position:'absolute', left:'0', top:'0', width:'100%', height:'100%', backgroundImage:`url(${block.fruitImage})`, backgroundSize:'contain', backgroundRepeat:'no-repeat', backgroundPosition:'center', backgroundColor:'transparent', mixBlendMode:'multiply', border:'none', boxSizing:'border-box', transform: block.fruitRotate?'rotate(180deg)':'none' }
|
? { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', backgroundImage: `url(${block.blockImage})`, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundColor: 'transparent', mixBlendMode: 'multiply', border: 'none', boxSizing: 'border-box', transform: block.blockRotate ? 'rotate(180deg)' : 'none' }
|
||||||
: { position:'absolute', left: unit.relX*getCellSize(gridDef.id)+'px', top: unit.relY*getCellSize(gridDef.id)+'px', width: getCellSize(gridDef.id)+'px', height: getCellSize(gridDef.id)+'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing:'border-box' }">
|
: { position: 'absolute', left: unit.relX * getCellSize(gridDef.id) + 'px', top: unit.relY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing: 'border-box' }">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!block.fruitImage"
|
<div v-if="!block.blockImage"
|
||||||
:style="{ position:'absolute', inset:0, backgroundImage: getBorderStyle(block), pointerEvents:'none' }"></div>
|
:style="{ position: 'absolute', inset: 0, backgroundImage: getBorderStyle(block), pointerEvents: 'none' }">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group outlines SVG -->
|
<!-- Group outlines SVG -->
|
||||||
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:10 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 10 }">
|
||||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5"
|
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||||
stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
class="group-outline-path" />
|
||||||
</svg>
|
</svg>
|
||||||
<!-- Solid border (target grid) -->
|
<!-- Solid border (target grid) -->
|
||||||
<svg :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:20 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 20 }">
|
||||||
<rect x="1" y="1"
|
<rect x="1" y="1" :width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
||||||
:width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
|
||||||
fill="none" stroke="#6366f1" stroke-width="2.5" rx="10" ry="10" />
|
fill="none" stroke="#6366f1" stroke-width="2.5" rx="10" ry="10" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid-label-below grid-label-below--target">{{ gridDef.label }}</div>
|
<div class="grid-label-below grid-label-below--target">{{ gridDef.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1688,28 +1720,23 @@ const getGroupOutlines = (gridId) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Drag ghost: replicates full block appearance (fruit image or colored units) -->
|
<!-- Drag ghost: replicates full block appearance (image or colored units) -->
|
||||||
<template v-if="ghostPos.visible && draggedBlockId !== null && groupContext !== null">
|
<template v-if="ghostPos.visible && draggedBlockId !== null && groupContext !== null">
|
||||||
<div v-for="pos in groupContext.initialPositions" :key="pos.id"
|
<div v-for="pos in groupContext.initialPositions" :key="pos.id" class="drag-ghost" :style="{
|
||||||
class="drag-ghost"
|
|
||||||
:style="{
|
|
||||||
left: (ghostPos.x + (pos.gridX - ghostPos.anchorGridX) * ghostPos.cellSize) + 'px',
|
left: (ghostPos.x + (pos.gridX - ghostPos.anchorGridX) * ghostPos.cellSize) + 'px',
|
||||||
top: (ghostPos.y + (pos.gridY - ghostPos.anchorGridY) * ghostPos.cellSize) + 'px',
|
top: (ghostPos.y + (pos.gridY - ghostPos.anchorGridY) * ghostPos.cellSize) + 'px',
|
||||||
width: ghostPos.cellSize + 'px',
|
width: ghostPos.cellSize + 'px',
|
||||||
height: ghostPos.cellSize + 'px',
|
height: ghostPos.cellSize + 'px',
|
||||||
}">
|
}">
|
||||||
<!-- render each unit inside the ghost block -->
|
<!-- render each unit inside the ghost block -->
|
||||||
<template v-if="blocks.find(b => b.id === pos.id)?.fruitImage">
|
<template v-if="blocks.find(b => b.id === pos.id)?.blockImage">
|
||||||
<div
|
<div v-for="(unit, ui) in blocks.find(b => b.id === pos.id)?.units ?? []" :key="ui" :style="{
|
||||||
v-for="(unit, ui) in blocks.find(b => b.id === pos.id)?.units ?? []"
|
|
||||||
:key="ui"
|
|
||||||
:style="{
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: unit.relX * ghostPos.cellSize + 'px',
|
left: unit.relX * ghostPos.cellSize + 'px',
|
||||||
top: unit.relY * ghostPos.cellSize + 'px',
|
top: unit.relY * ghostPos.cellSize + 'px',
|
||||||
width: ghostPos.cellSize + 'px',
|
width: ghostPos.cellSize + 'px',
|
||||||
height: ghostPos.cellSize + 'px',
|
height: ghostPos.cellSize + 'px',
|
||||||
backgroundImage: `url(${blocks.find(b => b.id === pos.id).fruitImage})`,
|
backgroundImage: `url(${blocks.find(b => b.id === pos.id).blockImage})`,
|
||||||
backgroundSize: '90%',
|
backgroundSize: '90%',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
@@ -1717,15 +1744,12 @@ const getGroupOutlines = (gridId) => {
|
|||||||
mixBlendMode: 'multiply',
|
mixBlendMode: 'multiply',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
transform: blocks.find(b => b.id === pos.id).fruitRotate ? 'rotate(180deg)' : 'none',
|
transform: blocks.find(b => b.id === pos.id).blockRotate ? 'rotate(180deg)' : 'none',
|
||||||
}">
|
}">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div v-for="(unit, ui) in blocks.find(b => b.id === pos.id)?.units ?? []" :key="ui" :style="{
|
||||||
v-for="(unit, ui) in blocks.find(b => b.id === pos.id)?.units ?? []"
|
|
||||||
:key="ui"
|
|
||||||
:style="{
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: unit.relX * ghostPos.cellSize + 'px',
|
left: unit.relX * ghostPos.cellSize + 'px',
|
||||||
top: unit.relY * ghostPos.cellSize + 'px',
|
top: unit.relY * ghostPos.cellSize + 'px',
|
||||||
@@ -1751,13 +1775,14 @@ const getGroupOutlines = (gridId) => {
|
|||||||
|
|
||||||
<!-- Static grids -->
|
<!-- Static grids -->
|
||||||
<div :class="['zone-sources', cfg.layout === 'column' ? 'zone-sources--col' : 'zone-sources--row']">
|
<div :class="['zone-sources', cfg.layout === 'column' ? 'zone-sources--col' : 'zone-sources--row']">
|
||||||
<div v-for="gridDef in gridDefs" :key="gridDef.id" class="grid-wrapper" style="position:relative">
|
<div v-for="gridDef in gridDefs" :key="gridDef.id" class="grid-wrapper">
|
||||||
|
|
||||||
<div class="grid-center inline-grid inline-grid--static" :data-grid-index="gridDef.id">
|
<div class="grid-center inline-grid inline-grid--static" :data-grid-index="gridDef.id">
|
||||||
<div class="grid-body grid-body--no-titlebar">
|
<div class="grid-body grid-body--no-titlebar">
|
||||||
<div class="grid-transparent"
|
<div :class="['grid-transparent', { 'grid--no-cell-borders': !cfg.showCellBorders }]"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||||
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`" class="grid-row-transparent">
|
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`"
|
||||||
|
class="grid-row-transparent">
|
||||||
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
||||||
class="grid-cell-transparent"
|
class="grid-cell-transparent"
|
||||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||||
@@ -1765,55 +1790,50 @@ const getGroupOutlines = (gridId) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="blocks-layer-transparent"
|
<div class="blocks-layer-transparent"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id" class="block-transparent no-hover"
|
||||||
class="block-transparent no-hover"
|
:style="{
|
||||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px',
|
left: block.gridX * getCellSize(gridDef.id) + 'px',
|
||||||
top: block.gridY * getCellSize(gridDef.id) + 'px',
|
top: block.gridY * getCellSize(gridDef.id) + 'px',
|
||||||
width: getCellSize(gridDef.id) + 'px',
|
width: getCellSize(gridDef.id) + 'px',
|
||||||
height: getCellSize(gridDef.id) + 'px',
|
height: getCellSize(gridDef.id) + 'px',
|
||||||
position: 'absolute', backgroundColor: 'transparent',
|
position: 'absolute', backgroundColor: 'transparent',
|
||||||
cursor: 'default' }">
|
cursor: 'default'
|
||||||
|
}">
|
||||||
<template v-if="block.units?.length > 0">
|
<template v-if="block.units?.length > 0">
|
||||||
<div v-for="(unit, i) in block.units" :key="i"
|
<div v-for="(unit, i) in block.units" :key="i"
|
||||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||||
:style="block.fruitImage
|
:style="block.blockImage
|
||||||
? { position:'absolute', left:'0', top:'0', width:'100%', height:'100%', backgroundImage:`url(${block.fruitImage})`, backgroundSize:'contain', backgroundRepeat:'no-repeat', backgroundPosition:'center', backgroundColor:'transparent', mixBlendMode:'multiply', border:'none', boxSizing:'border-box', transform: block.fruitRotate?'rotate(180deg)':'none' }
|
? { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', backgroundImage: `url(${block.blockImage})`, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundColor: 'transparent', mixBlendMode: 'multiply', border: 'none', boxSizing: 'border-box', transform: block.blockRotate ? 'rotate(180deg)' : 'none' }
|
||||||
: { position:'absolute', left: unit.relX*getCellSize(gridDef.id)+'px', top: unit.relY*getCellSize(gridDef.id)+'px', width: getCellSize(gridDef.id)+'px', height: getCellSize(gridDef.id)+'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing:'border-box' }">
|
: { position: 'absolute', left: unit.relX * getCellSize(gridDef.id) + 'px', top: unit.relY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing: 'border-box' }">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!block.fruitImage"
|
<div v-if="!block.blockImage"
|
||||||
:style="{ position:'absolute', inset:0, backgroundImage: getBorderStyle(block), pointerEvents:'none' }"></div>
|
:style="{ position: 'absolute', inset: 0, backgroundImage: getBorderStyle(block), pointerEvents: 'none' }">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Solid border (static grid) -->
|
<!-- Solid border (static grid) -->
|
||||||
<svg :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg v-if="!cfg.hideBorders" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:20 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 20 }">
|
||||||
<rect x="1" y="1"
|
<rect x="1" y="1" :width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
||||||
:width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
|
||||||
fill="none" stroke="#94a3b8" stroke-width="2" rx="10" ry="10" />
|
fill="none" stroke="#94a3b8" stroke-width="2" rx="10" ry="10" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="cfg.showGridLabels" class="grid-label-below">{{ gridDef.label }}</div>
|
<div v-if="cfg.showGridLabels && gridDef.label" class="grid-label-below">{{ gridDef.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Answer input slot -->
|
<!-- Answer input slot -->
|
||||||
<div class="input-answer-slot">
|
<div class="input-answer-slot">
|
||||||
<input
|
<input class="answer-input"
|
||||||
class="answer-input"
|
|
||||||
:class="{ 'answer-input--correct': confirmResult === 'correct', 'answer-input--incorrect': confirmResult === 'incorrect' }"
|
:class="{ 'answer-input--correct': confirmResult === 'correct', 'answer-input--incorrect': confirmResult === 'incorrect' }"
|
||||||
type="number" min="0" max="99"
|
type="number" min="0" max="99" v-model="inputAnswer" placeholder="?" @keyup.enter="handleInputConfirm"
|
||||||
v-model="inputAnswer"
|
|
||||||
placeholder="?"
|
|
||||||
@keyup.enter="handleInputConfirm"
|
|
||||||
:disabled="confirmResult === 'correct'" />
|
:disabled="confirmResult === 'correct'" />
|
||||||
<button class="confirm-btn confirm-btn--sm"
|
<button class="confirm-btn confirm-btn--sm"
|
||||||
:class="{ 'confirm-btn--correct': confirmResult === 'correct', 'confirm-btn--incorrect': confirmResult === 'incorrect' }"
|
:class="{ 'confirm-btn--correct': confirmResult === 'correct', 'confirm-btn--incorrect': confirmResult === 'incorrect' }"
|
||||||
@click="handleInputConfirm"
|
@click="handleInputConfirm" :disabled="confirmResult === 'correct'">
|
||||||
:disabled="confirmResult === 'correct'">
|
|
||||||
{{ confirmResult === 'correct' ? '✓' : confirmResult === 'incorrect' ? '✗' : 'OK' }}
|
{{ confirmResult === 'correct' ? '✓' : confirmResult === 'incorrect' ? '✗' : 'OK' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1826,18 +1846,19 @@ const getGroupOutlines = (gridId) => {
|
|||||||
<template v-else-if="cfg.exType === 'mark-options'">
|
<template v-else-if="cfg.exType === 'mark-options'">
|
||||||
<div ref="containerRef" class="ex-body ex-body--mark">
|
<div ref="containerRef" class="ex-body ex-body--mark">
|
||||||
<div class="zone-sources zone-sources--row">
|
<div class="zone-sources zone-sources--row">
|
||||||
<div v-for="gridDef in gridDefs" :key="gridDef.id"
|
<div v-for="gridDef in gridDefs" :key="gridDef.id" :class="['mark-option',
|
||||||
:class="['mark-option',
|
{
|
||||||
{ 'mark-option--marked': markedId === gridDef.sourceId,
|
'mark-option--marked': markedId === gridDef.sourceId,
|
||||||
'mark-option--correct': markedId === gridDef.sourceId && confirmResult === 'correct',
|
'mark-option--correct': markedId === gridDef.sourceId && confirmResult === 'correct',
|
||||||
'mark-option--incorrect': markedId === gridDef.sourceId && confirmResult === 'incorrect' }]"
|
'mark-option--incorrect': markedId === gridDef.sourceId && confirmResult === 'incorrect'
|
||||||
@click="handleMarkOption(gridDef.sourceId)">
|
}]" @click="handleMarkOption(gridDef.sourceId)">
|
||||||
|
|
||||||
<div class="grid-center inline-grid inline-grid--static" :data-grid-index="gridDef.id">
|
<div class="grid-center inline-grid inline-grid--static" :data-grid-index="gridDef.id">
|
||||||
<div class="grid-body grid-body--no-titlebar">
|
<div class="grid-body grid-body--no-titlebar">
|
||||||
<div class="grid-transparent"
|
<div :class="['grid-transparent', { 'grid--no-cell-borders': !cfg.showCellBorders }]"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||||
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`" class="grid-row-transparent">
|
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`"
|
||||||
|
class="grid-row-transparent">
|
||||||
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
||||||
class="grid-cell-transparent"
|
class="grid-cell-transparent"
|
||||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||||
@@ -1845,37 +1866,37 @@ const getGroupOutlines = (gridId) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="blocks-layer-transparent"
|
<div class="blocks-layer-transparent"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id" class="block-transparent no-hover"
|
||||||
class="block-transparent no-hover"
|
:style="{
|
||||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px',
|
left: block.gridX * getCellSize(gridDef.id) + 'px',
|
||||||
top: block.gridY * getCellSize(gridDef.id) + 'px',
|
top: block.gridY * getCellSize(gridDef.id) + 'px',
|
||||||
width: getCellSize(gridDef.id) + 'px',
|
width: getCellSize(gridDef.id) + 'px',
|
||||||
height: getCellSize(gridDef.id) + 'px',
|
height: getCellSize(gridDef.id) + 'px',
|
||||||
position: 'absolute', backgroundColor: 'transparent', cursor: 'pointer' }">
|
position: 'absolute', backgroundColor: 'transparent', cursor: 'pointer'
|
||||||
|
}">
|
||||||
<template v-if="block.units?.length > 0">
|
<template v-if="block.units?.length > 0">
|
||||||
<div v-for="(unit, i) in block.units" :key="i"
|
<div v-for="(unit, i) in block.units" :key="i"
|
||||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||||
:style="block.fruitImage
|
:style="block.blockImage
|
||||||
? { position:'absolute', left:'0', top:'0', width:'100%', height:'100%', backgroundImage:`url(${block.fruitImage})`, backgroundSize:'contain', backgroundRepeat:'no-repeat', backgroundPosition:'center', backgroundColor:'transparent', mixBlendMode:'multiply', border:'none', boxSizing:'border-box', transform: block.fruitRotate?'rotate(180deg)':'none' }
|
? { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', backgroundImage: `url(${block.blockImage})`, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundColor: 'transparent', mixBlendMode: 'multiply', border: 'none', boxSizing: 'border-box', transform: block.blockRotate ? 'rotate(180deg)' : 'none' }
|
||||||
: { position:'absolute', left: unit.relX*getCellSize(gridDef.id)+'px', top: unit.relY*getCellSize(gridDef.id)+'px', width: getCellSize(gridDef.id)+'px', height: getCellSize(gridDef.id)+'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing:'border-box' }">
|
: { position: 'absolute', left: unit.relX * getCellSize(gridDef.id) + 'px', top: unit.relY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing: 'border-box' }">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!block.fruitImage"
|
<div v-if="!block.blockImage"
|
||||||
:style="{ position:'absolute', inset:0, backgroundImage: getBorderStyle(block), pointerEvents:'none' }"></div>
|
:style="{ position: 'absolute', inset: 0, backgroundImage: getBorderStyle(block), pointerEvents: 'none' }">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Solid border (static grid) -->
|
<!-- Solid border (static grid) -->
|
||||||
<svg :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg v-if="!cfg.hideBorders" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:20 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 20 }">
|
||||||
<rect x="1" y="1"
|
<rect x="1" y="1" :width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
||||||
:width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
|
||||||
fill="none" stroke="#94a3b8" stroke-width="2" rx="10" ry="10" />
|
fill="none" stroke="#94a3b8" stroke-width="2" rx="10" ry="10" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid-label-below">{{ gridDef.label }}</div>
|
<div v-if="gridDef.label" class="grid-label-below">{{ gridDef.label }}</div>
|
||||||
<!-- X mark overlay -->
|
<!-- X mark overlay -->
|
||||||
<div v-if="markedId === gridDef.sourceId" class="mark-x">✕</div>
|
<div v-if="markedId === gridDef.sourceId" class="mark-x">✕</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1892,15 +1913,18 @@ const getGroupOutlines = (gridId) => {
|
|||||||
|
|
||||||
<!-- Left column: source grids at even positions (0, 2, 4…) -->
|
<!-- Left column: source grids at even positions (0, 2, 4…) -->
|
||||||
<div class="sides-col sides-col--left">
|
<div class="sides-col sides-col--left">
|
||||||
<div v-for="gridDef in sidesLeftGrids" :key="gridDef.id" class="grid-wrapper" style="position:relative">
|
<div v-for="gridDef in sidesLeftGrids" :key="gridDef.id" class="grid-wrapper">
|
||||||
|
|
||||||
<div :class="['grid-center', 'inline-grid']" :data-grid-index="gridDef.id"
|
<div :class="['grid-center', 'inline-grid']" :data-grid-index="gridDef.id"
|
||||||
|
:style="cfg.gridPadding ? { padding: cfg.gridPadding + 'px', boxSizing: 'content-box' } : {}"
|
||||||
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
||||||
<div class="grid-body grid-body--no-titlebar">
|
<div class="grid-body grid-body--no-titlebar">
|
||||||
<div class="grid-transparent" :class="{ 'cutting-cursor': cuttingMode }"
|
<div
|
||||||
|
:class="['grid-transparent', { 'cutting-cursor': cuttingMode, 'grid--no-cell-borders': !cfg.showCellBorders }]"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
||||||
@mousedown="handleGridCanvasClick">
|
@mousedown="handleGridCanvasClick">
|
||||||
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`" class="grid-row-transparent">
|
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`"
|
||||||
|
class="grid-row-transparent">
|
||||||
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
||||||
class="grid-cell-transparent"
|
class="grid-cell-transparent"
|
||||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||||
@@ -1914,40 +1938,46 @@ const getGroupOutlines = (gridId) => {
|
|||||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px', top: block.gridY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', position: 'absolute', backgroundColor: 'transparent' }">
|
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px', top: block.gridY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', position: 'absolute', backgroundColor: 'transparent' }">
|
||||||
<template v-if="block.units?.length > 0">
|
<template v-if="block.units?.length > 0">
|
||||||
<div v-for="(unit, i) in block.units" :key="i"
|
<div v-for="(unit, i) in block.units" :key="i"
|
||||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||||
:style="block.fruitImage ? { position:'absolute', left:'0', top:'0', width:'100%', height:'100%', backgroundImage:`url(${block.fruitImage})`, backgroundSize:'contain', backgroundRepeat:'no-repeat', backgroundPosition:'center', backgroundColor:'transparent', mixBlendMode:'multiply', border:'none', boxSizing:'border-box', transform: block.fruitRotate?'rotate(180deg)':'none' } : { position:'absolute', left: unit.relX*getCellSize(gridDef.id)+'px', top: unit.relY*getCellSize(gridDef.id)+'px', width: getCellSize(gridDef.id)+'px', height: getCellSize(gridDef.id)+'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing:'border-box' }">
|
:style="block.blockImage ? { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', backgroundImage: `url(${block.blockImage})`, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundColor: 'transparent', mixBlendMode: 'multiply', border: 'none', boxSizing: 'border-box', transform: block.blockRotate ? 'rotate(180deg)' : 'none' } : { position: 'absolute', left: unit.relX * getCellSize(gridDef.id) + 'px', top: unit.relY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing: 'border-box' }">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!block.fruitImage" :style="{ position:'absolute', inset:0, backgroundImage: getBorderStyle(block), pointerEvents:'none' }"></div>
|
<div v-if="!block.blockImage"
|
||||||
|
:style="{ position: 'absolute', inset: 0, backgroundImage: getBorderStyle(block), pointerEvents: 'none' }">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:10 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 10 }">
|
||||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||||
|
class="group-outline-path" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-if="!cfg.hideBorders" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg v-if="!cfg.hideBorders" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:20 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 20 }">
|
||||||
<rect x="1" y="1" :width="getGridWidth(gridDef.id)-2" :height="getGridHeight(gridDef.id)-2"
|
<rect x="1" y="1" :width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
||||||
fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="8 5" rx="10" ry="10" />
|
fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="8 5" rx="10" ry="10" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="grid-label-below">{{ gridDef.label }}</div>
|
</div>
|
||||||
|
<div v-if="gridDef.label" class="grid-label-below">{{ gridDef.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: target grid -->
|
<!-- Center: target grid -->
|
||||||
<div class="sides-col sides-col--center">
|
<div class="sides-col sides-col--center">
|
||||||
<div v-for="gridDef in sidesTargetGrids" :key="gridDef.id" class="grid-wrapper" style="position:relative">
|
<div v-for="gridDef in sidesTargetGrids" :key="gridDef.id" class="grid-wrapper">
|
||||||
|
|
||||||
<div :class="['grid-center', 'inline-grid']" :data-grid-index="gridDef.id"
|
<div :class="['grid-center', 'inline-grid']" :data-grid-index="gridDef.id"
|
||||||
|
:style="cfg.gridPadding ? { padding: cfg.gridPadding + 'px', boxSizing: 'content-box' } : {}"
|
||||||
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
||||||
<div class="grid-body grid-body--no-titlebar">
|
<div class="grid-body grid-body--no-titlebar">
|
||||||
<div class="grid-transparent" :class="{ 'cutting-cursor': cuttingMode }"
|
<div
|
||||||
|
:class="['grid-transparent', { 'cutting-cursor': cuttingMode, 'grid--no-cell-borders': !cfg.showCellBorders }]"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
||||||
@mousedown="handleGridCanvasClick">
|
@mousedown="handleGridCanvasClick">
|
||||||
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`" class="grid-row-transparent">
|
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`"
|
||||||
|
class="grid-row-transparent">
|
||||||
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
||||||
class="grid-cell-transparent"
|
class="grid-cell-transparent"
|
||||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||||
@@ -1961,25 +1991,28 @@ const getGroupOutlines = (gridId) => {
|
|||||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px', top: block.gridY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', position: 'absolute', backgroundColor: 'transparent' }">
|
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px', top: block.gridY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', position: 'absolute', backgroundColor: 'transparent' }">
|
||||||
<template v-if="block.units?.length > 0">
|
<template v-if="block.units?.length > 0">
|
||||||
<div v-for="(unit, i) in block.units" :key="i"
|
<div v-for="(unit, i) in block.units" :key="i"
|
||||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||||
:style="block.fruitImage ? { position:'absolute', left:'0', top:'0', width:'100%', height:'100%', backgroundImage:`url(${block.fruitImage})`, backgroundSize:'contain', backgroundRepeat:'no-repeat', backgroundPosition:'center', backgroundColor:'transparent', mixBlendMode:'multiply', border:'none', boxSizing:'border-box', transform: block.fruitRotate?'rotate(180deg)':'none' } : { position:'absolute', left: unit.relX*getCellSize(gridDef.id)+'px', top: unit.relY*getCellSize(gridDef.id)+'px', width: getCellSize(gridDef.id)+'px', height: getCellSize(gridDef.id)+'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing:'border-box' }">
|
:style="block.blockImage ? { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', backgroundImage: `url(${block.blockImage})`, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundColor: 'transparent', mixBlendMode: 'multiply', border: 'none', boxSizing: 'border-box', transform: block.blockRotate ? 'rotate(180deg)' : 'none' } : { position: 'absolute', left: unit.relX * getCellSize(gridDef.id) + 'px', top: unit.relY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing: 'border-box' }">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!block.fruitImage" :style="{ position:'absolute', inset:0, backgroundImage: getBorderStyle(block), pointerEvents:'none' }"></div>
|
<div v-if="!block.blockImage"
|
||||||
|
:style="{ position: 'absolute', inset: 0, backgroundImage: getBorderStyle(block), pointerEvents: 'none' }">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:10 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 10 }">
|
||||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||||
|
class="group-outline-path" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:20 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 20 }">
|
||||||
<rect x="1" y="1" :width="getGridWidth(gridDef.id)-2" :height="getGridHeight(gridDef.id)-2"
|
<rect x="1" y="1" :width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
||||||
fill="none" stroke="#6366f1" stroke-width="2.5" rx="10" ry="10" />
|
fill="none" stroke="#6366f1" stroke-width="2.5" rx="10" ry="10" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="grid-label-below grid-label-below--target">{{ gridDef.label }}</div>
|
<div class="grid-label-below grid-label-below--target">{{ gridDef.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="cfg._targetAnswers?.length" class="confirm-wrap" style="margin-top:8px">
|
<div v-if="cfg._targetAnswers?.length" class="confirm-wrap" style="margin-top:8px">
|
||||||
@@ -1993,15 +2026,18 @@ const getGroupOutlines = (gridId) => {
|
|||||||
|
|
||||||
<!-- Right column: source grids at odd positions (1, 3, 5…) -->
|
<!-- Right column: source grids at odd positions (1, 3, 5…) -->
|
||||||
<div class="sides-col sides-col--right">
|
<div class="sides-col sides-col--right">
|
||||||
<div v-for="gridDef in sidesRightGrids" :key="gridDef.id" class="grid-wrapper" style="position:relative">
|
<div v-for="gridDef in sidesRightGrids" :key="gridDef.id" class="grid-wrapper">
|
||||||
|
|
||||||
<div :class="['grid-center', 'inline-grid']" :data-grid-index="gridDef.id"
|
<div :class="['grid-center', 'inline-grid']" :data-grid-index="gridDef.id"
|
||||||
|
:style="cfg.gridPadding ? { padding: cfg.gridPadding + 'px', boxSizing: 'content-box' } : {}"
|
||||||
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
||||||
<div class="grid-body grid-body--no-titlebar">
|
<div class="grid-body grid-body--no-titlebar">
|
||||||
<div class="grid-transparent" :class="{ 'cutting-cursor': cuttingMode }"
|
<div
|
||||||
|
:class="['grid-transparent', { 'cutting-cursor': cuttingMode, 'grid--no-cell-borders': !cfg.showCellBorders }]"
|
||||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }"
|
||||||
@mousedown="handleGridCanvasClick">
|
@mousedown="handleGridCanvasClick">
|
||||||
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`" class="grid-row-transparent">
|
<div v-for="y in getGridRows(gridDef.id)" :key="`row-${gridDef.id}-${y}`"
|
||||||
|
class="grid-row-transparent">
|
||||||
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
<div v-for="x in getGridCols(gridDef.id)" :key="`cell-${gridDef.id}-${x}-${y}`"
|
||||||
class="grid-cell-transparent"
|
class="grid-cell-transparent"
|
||||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||||
@@ -2015,26 +2051,29 @@ const getGroupOutlines = (gridId) => {
|
|||||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px', top: block.gridY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', position: 'absolute', backgroundColor: 'transparent' }">
|
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px', top: block.gridY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', position: 'absolute', backgroundColor: 'transparent' }">
|
||||||
<template v-if="block.units?.length > 0">
|
<template v-if="block.units?.length > 0">
|
||||||
<div v-for="(unit, i) in block.units" :key="i"
|
<div v-for="(unit, i) in block.units" :key="i"
|
||||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||||
:style="block.fruitImage ? { position:'absolute', left:'0', top:'0', width:'100%', height:'100%', backgroundImage:`url(${block.fruitImage})`, backgroundSize:'contain', backgroundRepeat:'no-repeat', backgroundPosition:'center', backgroundColor:'transparent', mixBlendMode:'multiply', border:'none', boxSizing:'border-box', transform: block.fruitRotate?'rotate(180deg)':'none' } : { position:'absolute', left: unit.relX*getCellSize(gridDef.id)+'px', top: unit.relY*getCellSize(gridDef.id)+'px', width: getCellSize(gridDef.id)+'px', height: getCellSize(gridDef.id)+'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing:'border-box' }">
|
:style="block.blockImage ? { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', backgroundImage: `url(${block.blockImage})`, backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundColor: 'transparent', mixBlendMode: 'multiply', border: 'none', boxSizing: 'border-box', transform: block.blockRotate ? 'rotate(180deg)' : 'none' } : { position: 'absolute', left: unit.relX * getCellSize(gridDef.id) + 'px', top: unit.relY * getCellSize(gridDef.id) + 'px', width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing: 'border-box' }">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!block.fruitImage" :style="{ position:'absolute', inset:0, backgroundImage: getBorderStyle(block), pointerEvents:'none' }"></div>
|
<div v-if="!block.blockImage"
|
||||||
|
:style="{ position: 'absolute', inset: 0, backgroundImage: getBorderStyle(block), pointerEvents: 'none' }">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg class="group-outline-svg" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:10 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 10 }">
|
||||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||||
|
class="group-outline-path" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-if="!cfg.hideBorders" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
<svg v-if="!cfg.hideBorders" :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||||
:style="{ position:'absolute', top:0, left:0, pointerEvents:'none', overflow:'visible', zIndex:20 }">
|
:style="{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none', overflow: 'visible', zIndex: 20 }">
|
||||||
<rect x="1" y="1" :width="getGridWidth(gridDef.id)-2" :height="getGridHeight(gridDef.id)-2"
|
<rect x="1" y="1" :width="getGridWidth(gridDef.id) - 2" :height="getGridHeight(gridDef.id) - 2"
|
||||||
fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="8 5" rx="10" ry="10" />
|
fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="8 5" rx="10" ry="10" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="grid-label-below">{{ gridDef.label }}</div>
|
</div>
|
||||||
|
<div v-if="gridDef.label" class="grid-label-below">{{ gridDef.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2042,14 +2081,14 @@ const getGroupOutlines = (gridId) => {
|
|||||||
<template v-if="ghostPos.visible && draggedBlockId !== null && groupContext !== null">
|
<template v-if="ghostPos.visible && draggedBlockId !== null && groupContext !== null">
|
||||||
<div v-for="pos in groupContext.initialPositions" :key="pos.id" class="drag-ghost"
|
<div v-for="pos in groupContext.initialPositions" :key="pos.id" class="drag-ghost"
|
||||||
:style="{ left: (ghostPos.x + (pos.gridX - ghostPos.anchorGridX) * ghostPos.cellSize) + 'px', top: (ghostPos.y + (pos.gridY - ghostPos.anchorGridY) * ghostPos.cellSize) + 'px', width: ghostPos.cellSize + 'px', height: ghostPos.cellSize + 'px' }">
|
:style="{ left: (ghostPos.x + (pos.gridX - ghostPos.anchorGridX) * ghostPos.cellSize) + 'px', top: (ghostPos.y + (pos.gridY - ghostPos.anchorGridY) * ghostPos.cellSize) + 'px', width: ghostPos.cellSize + 'px', height: ghostPos.cellSize + 'px' }">
|
||||||
<template v-if="blocks.find(b => b.id === pos.id)?.fruitImage">
|
<template v-if="blocks.find(b => b.id === pos.id)?.blockImage">
|
||||||
<div v-for="(unit, ui) in (blocks.find(b => b.id === pos.id)?.units ?? [])" :key="ui"
|
<div v-for="(unit, ui) in (blocks.find(b => b.id === pos.id)?.units ?? [])" :key="ui"
|
||||||
:style="{ backgroundImage:'url(' + blocks.find(b => b.id === pos.id).fruitImage + ')', backgroundSize:'90%', backgroundRepeat:'no-repeat', backgroundPosition:'center', backgroundColor:'transparent', mixBlendMode:'multiply', border:'none', transform: blocks.find(b => b.id === pos.id).fruitRotate ? 'rotate(180deg)' : 'none' }">
|
:style="{ backgroundImage: 'url(' + blocks.find(b => b.id === pos.id).blockImage + ')', backgroundSize: '90%', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundColor: 'transparent', mixBlendMode: 'multiply', border: 'none', transform: blocks.find(b => b.id === pos.id).blockRotate ? 'rotate(180deg)' : 'none' }">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-for="(unit, ui) in (blocks.find(b => b.id === pos.id)?.units ?? [])" :key="ui"
|
<div v-for="(unit, ui) in (blocks.find(b => b.id === pos.id)?.units ?? [])" :key="ui"
|
||||||
:style="{ position:'absolute', left: unit.relX*ghostPos.cellSize+'px', top: unit.relY*ghostPos.cellSize+'px', width: ghostPos.cellSize+'px', height: ghostPos.cellSize+'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing:'border-box' }">
|
:style="{ position: 'absolute', left: unit.relX * ghostPos.cellSize + 'px', top: unit.relY * ghostPos.cellSize + 'px', width: ghostPos.cellSize + 'px', height: ghostPos.cellSize + 'px', backgroundColor: unit.color, '--cube-color': unit.color, boxSizing: 'border-box' }">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -2061,7 +2100,6 @@ const getGroupOutlines = (gridId) => {
|
|||||||
<div class="ex-footer">
|
<div class="ex-footer">
|
||||||
<button class="btn-reset" @click="handleReset">↺ Reset</button>
|
<button class="btn-reset" @click="handleReset">↺ Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -2169,13 +2207,19 @@ const getGroupOutlines = (gridId) => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* showCellBorders: false — hide all cell grid lines */
|
||||||
|
.grid--no-cell-borders .grid-cell-transparent,
|
||||||
|
.grid--no-cell-borders .grid-row-transparent:first-child .grid-cell-transparent,
|
||||||
|
.grid--no-cell-borders .grid-cell-transparent:first-child {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.blocks-layer-transparent {
|
.blocks-layer-transparent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-transparent {
|
.block-transparent {
|
||||||
@@ -2196,6 +2240,14 @@ const getGroupOutlines = (gridId) => {
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 3D cube — gradient shading within cell bounds ─────────────────
|
||||||
|
Uses layered background-image gradients to simulate isometric shading
|
||||||
|
without pseudo-elements overflowing into neighbor cells.
|
||||||
|
- Top-left corner lighter → mimics top face light catch
|
||||||
|
- Right strip darker → mimics right face in shadow
|
||||||
|
- Strong bottom+right border → ground shadow / separation line
|
||||||
|
Works correctly in grids of any size with no z-index issues.
|
||||||
|
──────────────────────────────────────────────────────────────────── */
|
||||||
.unit-cube {
|
.unit-cube {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 2px;
|
left: 2px;
|
||||||
@@ -2203,18 +2255,32 @@ const getGroupOutlines = (gridId) => {
|
|||||||
width: calc(100% - 4px);
|
width: calc(100% - 4px);
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 3px;
|
border-radius: 2px;
|
||||||
|
|
||||||
|
/* Layered gradients:
|
||||||
|
1. Top-left highlight → simulates top face
|
||||||
|
2. Right-side shadow → simulates right face
|
||||||
|
3. Bottom-left ambient shadow */
|
||||||
|
background-image:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.45) 0%, transparent 45%),
|
||||||
|
linear-gradient(to right, transparent 68%, rgba(0, 0, 0, 0.22) 100%),
|
||||||
|
linear-gradient(to bottom, transparent 68%, rgba(0, 0, 0, 0.15) 100%);
|
||||||
|
background-blend-mode: normal, multiply, multiply;
|
||||||
|
|
||||||
|
/* Border: thin+light on top-left (highlight), thick+dark on bottom-right (shadow) */
|
||||||
|
border-top: 1.5px solid rgba(0, 0, 0, 0.30);
|
||||||
|
border-left: 1.5px solid rgba(0, 0, 0, 0.30);
|
||||||
|
border-right: 2.5px solid rgba(0, 0, 0, 0.55);
|
||||||
|
border-bottom: 2.5px solid rgba(0, 0, 0, 0.55);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.65rem;
|
font-size: 0.6rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: rgba(0, 0, 0, 0.5);
|
||||||
text-shadow: 0 1px 2px rgba(0,0,0,0.4);
|
text-shadow: none;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.15s ease;
|
||||||
border: 2px solid rgba(255,255,255,0.25);
|
|
||||||
border-bottom-color: rgba(0,0,0,0.2);
|
|
||||||
border-right-color: rgba(0,0,0,0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit-cube::before,
|
.unit-cube::before,
|
||||||
@@ -2222,7 +2288,8 @@ const getGroupOutlines = (gridId) => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit-cube--fruit {
|
/* Fruit blocks: fill full cell, no 3D shading */
|
||||||
|
.unit-cube--image {
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2230,6 +2297,7 @@ const getGroupOutlines = (gridId) => {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-image: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-transparent.block-connected {
|
.block-transparent.block-connected {
|
||||||
@@ -2584,8 +2652,13 @@ const getGroupOutlines = (gridId) => {
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone-sources--row { flex-direction: row; }
|
.zone-sources--row {
|
||||||
.zone-sources--col { flex-direction: column; }
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-sources--col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.zone-targets {
|
.zone-targets {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2675,11 +2748,14 @@ const getGroupOutlines = (gridId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.answer-input::-webkit-outer-spin-button,
|
.answer-input::-webkit-outer-spin-button,
|
||||||
.answer-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
.answer-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.answer-input:focus {
|
.answer-input:focus {
|
||||||
border-color: #6366f1;
|
border-color: #6366f1;
|
||||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-input--correct {
|
.answer-input--correct {
|
||||||
@@ -2707,14 +2783,15 @@ const getGroupOutlines = (gridId) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 16px;
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
border: 2px solid #e5e7eb;
|
border: 2px solid #e5e7eb;
|
||||||
background: white;
|
background: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
min-width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mark-option:hover {
|
.mark-option:hover {
|
||||||
@@ -2748,8 +2825,13 @@ const getGroupOutlines = (gridId) => {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mark-option--correct .mark-x { color: rgba(16, 185, 129, 0.85); }
|
.mark-option--correct .mark-x {
|
||||||
.mark-option--incorrect .mark-x { color: rgba(239, 68, 68, 0.85); }
|
color: rgba(16, 185, 129, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-option--incorrect .mark-x {
|
||||||
|
color: rgba(239, 68, 68, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ===== SIDES LAYOUT ===== */
|
/* ===== SIDES LAYOUT ===== */
|
||||||
@@ -2769,8 +2851,18 @@ const getGroupOutlines = (gridId) => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sides-col--left { flex: 1; align-items: flex-end; }
|
.sides-col--left {
|
||||||
.sides-col--center { flex-shrink: 0; align-items: center; }
|
flex: 1;
|
||||||
.sides-col--right { flex: 1; align-items: flex-start; }
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sides-col--center {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sides-col--right {
|
||||||
|
flex: 1;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
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>
|
||||||
2853
package-lock.json
generated
Normal file
2853
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user