Add exercise type components
Add functioning exercise components for support. Includes test exercises to make sure they work. ComposeDecompose: Takes a value and shows exercise with 2 inputs and hints automaticly. Can change the ammount of options it has. Connectlines: Exercise to connect between eachother. Has two modes, sum and picture, sum uses a 1+1 style of options, and picture uses the grid layout for more complexity. Picture in the middle is currently default blocks, this has to be conversed of how it should be displayed. PictureChoose.vue: This one shows grid with the first and second value, the correct answer must have a boolean value stating this is the correct one. Substractvisual.vue: This one is the most loose, as it only uses the grids as visuals and answer purposes. Not more than that. Wheremore.vue: Has two modes, less or more (if non it defaults to more), Takes a left value and a right value to create the execise. Examples of the structure are detailed in each component. And app.vue has demo exercises for testing and visual purposes.
This commit is contained in:
@@ -59,8 +59,10 @@
|
||||
<div v-for="(ex, i) in draft" :key="i" class="draft-item"
|
||||
:class="{ 'draft-item--pinned': pinnedPreview === ex }"
|
||||
@click="pinnedPreview = pinnedPreview === ex ? null : ex">
|
||||
<span class="draft-nums">{{ ex.grids.filter(g => g.mode !== 'target').map(g => g.label || g.value).join(' + ') }}</span>
|
||||
<span class="draft-dropzones">→ {{ ex.grids.filter(g => g.mode === 'target').map(g => g.label).join(', ') || 'sin banca' }}</span>
|
||||
<span class="draft-nums">{{ex.grids.filter(g => g.mode !== 'target').map(g => g.label ||
|
||||
g.value).join(' + ')}}</span>
|
||||
<span class="draft-dropzones">→ {{ex.grids.filter(g => g.mode === 'target').map(g => g.label).join(',')
|
||||
|| 'sin banca' }}</span>
|
||||
<button class="btn-icon btn-delete"
|
||||
@click.stop="draft.splice(i, 1); if (pinnedPreview === ex) pinnedPreview = null">✕</button>
|
||||
</div>
|
||||
@@ -79,23 +81,29 @@
|
||||
@click="form.exerciseType = 'input-check'">Escribir número</button>
|
||||
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'sides' }"
|
||||
@click="form.exerciseType = 'sides'">Lados ↔</button>
|
||||
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'mark-options' }"
|
||||
@click="form.exerciseType = 'mark-options'">Marcar ✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout -->
|
||||
<div class="field" v-if="form.exerciseType !== 'sides'">
|
||||
<div class="field" v-if="form.exerciseType !== 'sides' && form.exerciseType !== 'mark-options'">
|
||||
<label>Disposición</label>
|
||||
<div class="mode-toggle">
|
||||
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row' }"
|
||||
@click="form.layout = 'row'">Fila →</button>
|
||||
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'column' }"
|
||||
@click="form.layout = 'column'">Columna ↓</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row-answer-right' }"
|
||||
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'row' }" @click="form.layout = 'row'">Fila →</button>
|
||||
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'column' }" @click="form.layout = 'column'">Columna
|
||||
↓</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'row-answer-right' }"
|
||||
@click="form.layout = 'row-answer-right'">Resp. →</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row-answer-bottom' }"
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'row-answer-bottom' }"
|
||||
@click="form.layout = 'row-answer-bottom'">Resp. ↓</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'column' }"
|
||||
@click="form.layout = 'column'">Columna ↓</button>
|
||||
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn"
|
||||
:class="{ 'mode-btn--active': form.layout === 'column' }" @click="form.layout = 'column'">Columna
|
||||
↓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,8 +146,8 @@
|
||||
v-model="g.label" />
|
||||
</div>
|
||||
<!-- Fruit picker — dropdown select -->
|
||||
<div v-if="g.value > 0" class="fruit-select-wrap">
|
||||
<select class="fruit-select" v-model="g.fruit">
|
||||
<div v-if="g.value > 0" class="image-select-wrap">
|
||||
<select class="image-select" v-model="g.image">
|
||||
<option :value="null">🟥 Bloques de color</option>
|
||||
<option v-for="f in FRUITS" :key="f.key" :value="f.key">
|
||||
{{ f.emoji }} {{ f.label }}
|
||||
@@ -150,8 +158,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mark-options: which grid is correct -->
|
||||
<div class="field" v-if="form.exerciseType === 'mark-options' && validGrids.length >= 2">
|
||||
<label>Respuesta correcta</label>
|
||||
<select class="image-select" v-model="form.correctGridIdx" style="max-width:220px">
|
||||
<option v-for="(g, i) in validGrids" :key="i" :value="i">
|
||||
{{ g.label || ('Grid ' + (form.grids.indexOf(g) + 1)) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- move-blocks / sides: banca config -->
|
||||
<div class="field" v-if="(form.exerciseType === 'move-blocks' || form.exerciseType === 'sides') && validGrids.length">
|
||||
<div class="field"
|
||||
v-if="(form.exerciseType === 'move-blocks' || form.exerciseType === 'sides') && validGrids.length">
|
||||
<label>Banca <span class="hint">— respuesta esperada por origen</span></label>
|
||||
<div class="grid-row" style="margin-bottom:0.5rem">
|
||||
<input class="grid-label-input" type="text" placeholder="Etiqueta respuesta" v-model="form.bancaLabel"
|
||||
@@ -214,20 +233,19 @@ import Blocks from './components/Blocks.vue';
|
||||
const screen = ref(null);
|
||||
|
||||
// ── FRUIT CATALOGUE ──
|
||||
// Keys must match what Blocks.vue / FRUIT_DICT accepts
|
||||
// Keys must match FRUIT_DICT in Blocks.vue exactly
|
||||
// Keep in sync with IMAGE_DICT in Blocks.vue manually when adding/removing images.
|
||||
const FRUITS = [
|
||||
{ key: 'manzana', emoji: '🍎', label: 'Manzana' },
|
||||
{ key: 'banana', emoji: '🍌', label: 'Banana' },
|
||||
{ key: 'naranja', emoji: '🍊', label: 'Naranja' },
|
||||
{ key: 'frutilla', emoji: '🍓', label: 'Frutilla' },
|
||||
{ key: 'uva', emoji: '🍇', label: 'Uva' },
|
||||
{ key: 'limon', emoji: '🍋', label: 'Limón' },
|
||||
{ key: 'zanahoria', emoji: '🥕', label: 'Zanahoria' },
|
||||
{ key: 'limon', emoji: '🍋', label: 'Limón' },
|
||||
{ key: 'mango', emoji: '🥭', label: 'Mango' },
|
||||
{ key: 'uva', emoji: '🍇', label: 'Uva' },
|
||||
{ key: 'tomate', emoji: '🍅', label: 'Tomate' },
|
||||
{ key: 'palta', emoji: '🥑', label: 'Palta' },
|
||||
{ key: 'kiwi', emoji: '🥝', label: 'Kiwi' },
|
||||
{ key: 'naranja', emoji: '🍊', label: 'Naranja' },
|
||||
{ key: 'pina', emoji: '🍍', label: 'Piña' },
|
||||
];
|
||||
|
||||
@@ -242,73 +260,224 @@ const FRUITS = [
|
||||
// answerSlot: { expects: number } (for input-check at exercise level)
|
||||
|
||||
const demoExercises = ref([
|
||||
// ── move-blocks: operandos activos, banca target a la derecha ──
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// NEW EXERCISE COMPONENTS
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
// ── compose-decompose ──
|
||||
{
|
||||
exerciseType: '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',
|
||||
layout: 'row-answer-right',
|
||||
sidesAnswerLabel: '',
|
||||
text: "María tiene 2 manzanas y Juan tiene 3. ¿Cuántas tienen en total?",
|
||||
grids: [
|
||||
{ id: 'maria', mode: 'active', value: 2, label: 'María', fruit: 'manzana' },
|
||||
{ id: 'juan', mode: 'active', value: 3, label: 'Juan', fruit: 'zanahoria' },
|
||||
{ id: 'maria', mode: 'active', value: 2, label: 'María', image: 'manzana' },
|
||||
{ id: 'juan', mode: 'active', value: 3, label: 'Juan', image: 'zanahoria' },
|
||||
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 5 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ── move-blocks: por origen específico, banca abajo ──
|
||||
// ── move-blocks: answer bottom, by origin ──
|
||||
{
|
||||
exerciseType: 'move-blocks',
|
||||
layout: 'row-answer-bottom',
|
||||
text: "Pedro tiene 3 manzanas y Alicia tiene 2. Pedro le da 2 a Alicia. ¿Cuántas tiene Alicia?",
|
||||
grids: [
|
||||
{ id: 'pedro', mode: 'active', value: 3, label: 'Pedro', fruit: 'manzana' },
|
||||
{ id: 'alicia', mode: 'active', value: 2, label: 'Alicia', fruit: 'frutilla' },
|
||||
{ id: 'resp', mode: 'target', label: 'Alicia al final',
|
||||
answerSlot: { from: [{ id: 'pedro', count: 2 }, { id: 'alicia', count: 2 }] } },
|
||||
],
|
||||
},
|
||||
|
||||
// ── input-check: grids estáticos, casilla de número a la derecha ──
|
||||
{ id: 'pedro', mode: 'active', value: 3, label: 'Pedro', image: 'manzana' },
|
||||
{ id: 'alicia', mode: 'active', value: 2, label: 'Alicia', image: 'frutilla' },
|
||||
{
|
||||
exerciseType: 'input-check',
|
||||
layout: 'row',
|
||||
answerPosition: 'right',
|
||||
hideBorders: false,
|
||||
text: "¿Cuántos hay en total?",
|
||||
answerSlot: { expects: 5 },
|
||||
grids: [
|
||||
{ id: 'pollos', mode: 'static', value: 2, label: 'Pollos', fruit: 'naranja' },
|
||||
{ id: 'hamburguesas', mode: 'static', value: 3, label: 'Hamburguesas' },
|
||||
id: 'resp', mode: 'target', label: 'Alicia al final',
|
||||
answerSlot: { from: [{ id: 'pedro', count: 2 }, { id: 'alicia', count: 2 }] }
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── input-check: casilla de número abajo ──
|
||||
{
|
||||
exerciseType: 'input-check',
|
||||
layout: 'row',
|
||||
answerPosition: 'bottom',
|
||||
text: "¿Cuántos perros hay en el patio?",
|
||||
answerSlot: { expects: 6 },
|
||||
grids: [
|
||||
{ id: 'g1', mode: 'static', value: 4, label: 'Grupo 1', fruit: 'banana' },
|
||||
{ id: 'g2', mode: 'static', value: 2, label: 'Grupo 2', fruit: 'banana' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── sides: banca en el centro, fuentes alternando izquierda/derecha ──
|
||||
// ── sides ──
|
||||
{
|
||||
exerciseType: 'sides',
|
||||
text: "Junta los bloques de ambos lados en el centro.",
|
||||
grids: [
|
||||
{ id: 'izq1', mode: 'active', value: 3, label: 'Grupo A', fruit: 'manzana' },
|
||||
{ id: 'der1', mode: 'active', value: 2, label: 'Grupo B', fruit: 'banana' },
|
||||
{ id: 'izq2', mode: 'active', value: 2, label: 'Grupo C', fruit: 'naranja' },
|
||||
{ id: 'resp', mode: 'target', label: 'Total',
|
||||
answerSlot: { from: 'any', count: 6 } },
|
||||
{ id: 'izq1', mode: 'active', value: 3, label: 'Grupo A', image: 'manzana' },
|
||||
{ id: 'der1', mode: 'active', value: 2, label: 'Grupo B', image: 'banana' },
|
||||
{ id: 'izq2', mode: 'active', value: 1, label: 'Grupo C', image: 'naranja' },
|
||||
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 6 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ── input-check ──
|
||||
{
|
||||
exerciseType: 'input-check',
|
||||
layout: 'row', answerPosition: 'right',
|
||||
text: "¿Cuántos hay en total?",
|
||||
answerSlot: { expects: 5 },
|
||||
grids: [
|
||||
{ id: 'g1', mode: 'static', value: 2, label: 'Grupo 1', image: 'naranja' },
|
||||
{ id: 'g2', mode: 'static', value: 3, label: 'Grupo 2' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── mark-options ──
|
||||
{
|
||||
exerciseType: 'mark-options',
|
||||
layout: 'row',
|
||||
text: "¿Dónde hay menos? Marca con una X.",
|
||||
answerSlot: { correctId: 'koalas' },
|
||||
grids: [
|
||||
{ id: 'pandas', mode: 'static', value: 6, label: 'Pandas' },
|
||||
{ id: 'koalas', mode: 'static', value: 5, label: 'Koalas' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── mark-options: dos grids estáticos, el alumno marca cuál tiene menos ── { exerciseType: 'mark-options', layout: 'row', text: "¿Dónde hay menos? Marca con una X.", answerSlot: { correctId: 'koalas' }, grids: [ { id: 'pandas', mode: 'static', value: 6, label: 'Pandas' }, { id: 'koalas', mode: 'static', value: 5, label: 'Koalas' }, ], },
|
||||
]);
|
||||
|
||||
// ── USER EXERCISES ──
|
||||
@@ -332,7 +501,7 @@ const resetAll = () => { demoKeys.value = demoKeys.value.map(k => k + 1000);
|
||||
const resetAllUser = () => { userKeys.value = userKeys.value.map(k => k + 1000); };
|
||||
|
||||
// ── CREATOR ──
|
||||
const defaultGrid = () => ({ value: '', label: '', needs: '', fruit: null });
|
||||
const defaultGrid = () => ({ value: '', label: '', needs: '', image: null });
|
||||
const defaultForm = () => ({
|
||||
text: '',
|
||||
exerciseType: 'move-blocks',
|
||||
@@ -360,7 +529,8 @@ const canAdd = computed(() => {
|
||||
return form.value.expectedAnswer !== '' && !isNaN(Number(form.value.expectedAnswer));
|
||||
if (form.value.exerciseType === 'sides')
|
||||
return validGrids.value.some(g => parseInt(g.needs) > 0);
|
||||
return validGrids.value.length >= 2; // mark-options
|
||||
if (form.value.exerciseType === 'mark-options') return validGrids.value.length >= 2;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Grid inputs — cascade: fill → next slot appears
|
||||
@@ -400,7 +570,7 @@ const previewExercise = computed(() => {
|
||||
mode: form.value.exerciseType === 'move-blocks' ? 'active' : 'static',
|
||||
value: parseInt(g.value),
|
||||
label: g.label || String(g.value),
|
||||
...(g.fruit ? { fruit: g.fruit } : {}),
|
||||
...(g.image ? { image: g.image } : {}),
|
||||
}));
|
||||
|
||||
if (form.value.exerciseType === 'move-blocks') {
|
||||
@@ -465,9 +635,10 @@ const previewExercise = computed(() => {
|
||||
// mark-options
|
||||
return {
|
||||
exerciseType: 'mark-options',
|
||||
hideBorders: form.value.hideBorders,
|
||||
layout: 'row',
|
||||
text: form.value.text,
|
||||
answerSlot: { correctId: srcGrids[0]?.id },
|
||||
answerSlot: { correctId: srcGrids[form.value.correctGridIdx]?.id ?? srcGrids[0]?.id },
|
||||
grids: srcGrids,
|
||||
};
|
||||
});
|
||||
@@ -691,8 +862,14 @@ const loadDraft = () => {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.needs-arrow { color: #9ca3af; }
|
||||
.needs-hint { color: #9ca3af; font-size: 0.75rem; }
|
||||
.needs-arrow {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.needs-hint {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.grid-num-input {
|
||||
width: 58px;
|
||||
@@ -711,7 +888,9 @@ const loadDraft = () => {
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.grid-num-input--sm { width: 46px; }
|
||||
.grid-num-input--sm {
|
||||
width: 46px;
|
||||
}
|
||||
|
||||
.grid-label-input {
|
||||
flex: 1;
|
||||
@@ -796,8 +975,14 @@ button {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
button:hover { background: #f0f0f0; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.2rem 0.45rem;
|
||||
@@ -806,7 +991,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-add-draft { align-self: flex-start; }
|
||||
.btn-add-draft {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn-load {
|
||||
background: #6366f1;
|
||||
@@ -814,7 +1001,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-load:hover { background: #4f46e5; }
|
||||
.btn-load:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
@@ -836,9 +1025,18 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-btn:hover { background: #f0f0f0; }
|
||||
.mode-btn--active { background: #6366f1; color: white; }
|
||||
.mode-btn--active:hover { background: #4f46e5; }
|
||||
.mode-btn:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.mode-btn--active {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-btn--active:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@@ -864,18 +1062,34 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.draft-item:last-child { border-bottom: none; }
|
||||
.draft-item:hover { background: #fafbff; }
|
||||
.draft-item--pinned { background: #eef2ff; }
|
||||
.draft-nums { font-weight: 600; color: #374151; }
|
||||
.draft-dropzones { color: #9ca3af; font-size: 0.78rem; }
|
||||
.draft-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.draft-item:hover {
|
||||
background: #fafbff;
|
||||
}
|
||||
|
||||
.draft-item--pinned {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.draft-nums {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.draft-dropzones {
|
||||
color: #9ca3af;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* ── Fruit picker (select) ── */
|
||||
.fruit-select-wrap {
|
||||
.image-select-wrap {
|
||||
padding: 2px 0 4px 26px;
|
||||
}
|
||||
|
||||
.fruit-select {
|
||||
.image-select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
@@ -888,9 +1102,8 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.fruit-select:focus {
|
||||
.image-select:focus {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,5 +1,19 @@
|
||||
<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 =====
|
||||
// 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.answerPosition (input-check): 'right' | 'bottom'
|
||||
// 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 }
|
||||
// @complete="({ correct }) => ..."
|
||||
const props = defineProps({
|
||||
@@ -28,7 +42,7 @@ const _gridShape = (n) => {
|
||||
// ===== CONFIG =====
|
||||
// Translates the new exercise schema into the internal gridDefs format.
|
||||
// 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)
|
||||
// _answerSlot (raw answerSlot from JSON, only on target grids)
|
||||
const cfg = computed(() => {
|
||||
@@ -55,10 +69,16 @@ const cfg = computed(() => {
|
||||
cols = Math.max(total + 1, 3);
|
||||
rows = 3;
|
||||
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 {
|
||||
const shape = _gridShape(g.value ?? 1);
|
||||
cols = shape.cols;
|
||||
rows = shape.rows;
|
||||
}
|
||||
initialBlocks = [g.value ?? 1];
|
||||
}
|
||||
|
||||
@@ -76,7 +96,7 @@ const cfg = computed(() => {
|
||||
// Interaction: active grids allow dragging; static and target don't initiate drag.
|
||||
interactive: mode === 'active',
|
||||
showLabel: isTarget,
|
||||
fruit: g.fruit ?? null,
|
||||
image: g.image ?? null,
|
||||
_answerSlot: isTarget ? (g.answerSlot ?? null) : null,
|
||||
};
|
||||
});
|
||||
@@ -109,6 +129,10 @@ const cfg = computed(() => {
|
||||
answerPosition: ex.answerPosition ?? 'right',
|
||||
showGridLabels: true,
|
||||
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,
|
||||
_targetAnswers,
|
||||
// input-check / mark-options store their answer at exercise level
|
||||
@@ -118,14 +142,14 @@ const cfg = computed(() => {
|
||||
// ===== FRUIT IMAGES =====
|
||||
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' },
|
||||
banana: { url: BASE + 'Banana%201/55_100.jpg' },
|
||||
frutilla: { url: BASE + 'Strawberry%201/0_100.jpg', rotate: true },
|
||||
zanahoria: { url: BASE + 'Carrot%201/r0_0_100.jpg', rotate: true },
|
||||
limon: { url: BASE + 'Lemon%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' },
|
||||
palta: { url: BASE + 'Avocado%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' },
|
||||
};
|
||||
|
||||
const getFruitUrl = (name) => name ? (FRUIT_DICT[name]?.url ?? null) : null;
|
||||
const getFruitRotate = (name) => name ? (FRUIT_DICT[name]?.rotate ?? false) : false;
|
||||
const getFruitUrl = (name) => name ? (IMAGE_DICT[name]?.url ?? null) : null;
|
||||
const getBlockRotate = (name) => name ? (IMAGE_DICT[name]?.rotate ?? false) : false;
|
||||
|
||||
// ===== GRID DEFINITIONS =====
|
||||
const buildGridDefs = (rawGrids) =>
|
||||
@@ -150,7 +174,7 @@ const buildGridDefs = (rawGrids) =>
|
||||
interactive: g.interactive ?? true,
|
||||
showLabel: g.showLabel ?? true,
|
||||
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.
|
||||
@@ -249,10 +273,13 @@ const containerRef = ref(null);
|
||||
const getGridCols = (gridId) => gridDefs.value.find(g => g.id === gridId)?.cols ?? 8;
|
||||
const getGridRows = (gridId) => gridDefs.value.find(g => g.id === gridId)?.rows ?? 7;
|
||||
|
||||
// Cell size: uniform across all grids, sized to fit the widest one.
|
||||
const getCellSize = (_gridId) => {
|
||||
const maxCols = Math.max(...gridDefs.value.map(g => g.cols));
|
||||
return Math.max(20, Math.min(36, Math.floor(330 / maxCols)));
|
||||
// Cell size: use explicit _cellSize if provided (for uniform sizing across grids),
|
||||
// otherwise calculate from the grid's own cols.
|
||||
const getCellSize = (gridId) => {
|
||||
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);
|
||||
@@ -266,9 +293,9 @@ const onResize = () => { borderStyleCache.clear(); };
|
||||
|
||||
// ===== BLOCK FACTORY =====
|
||||
// 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 fruitName = originGrid?.fruit ?? null;
|
||||
const imageName = originGrid?.image ?? null;
|
||||
return {
|
||||
id,
|
||||
type: "uno",
|
||||
@@ -277,13 +304,13 @@ const createBlock = (gridX, gridY, id, gridId = 0, originGridId = gridId) => {
|
||||
gridX,
|
||||
gridY,
|
||||
value: 1,
|
||||
color: BLOCK_COLOR,
|
||||
fruitImage: getFruitUrl(fruitName),
|
||||
fruitRotate: getFruitRotate(fruitName),
|
||||
color: colorOverride ?? BLOCK_COLOR,
|
||||
blockImage: getFruitUrl(imageName),
|
||||
blockRotate: getBlockRotate(imageName),
|
||||
isConnected: false,
|
||||
width: 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 occupied = new Set();
|
||||
// Always fill left→right, top→bottom — no random shuffle
|
||||
const candidates = [];
|
||||
for (let y = q.y0; y < q.y1; 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;
|
||||
for (const { x, y } of candidates) {
|
||||
if (placed >= count) break;
|
||||
if (occupied.has(`${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}`);
|
||||
placed++;
|
||||
}
|
||||
@@ -1527,7 +1550,12 @@ const getGroupOutlines = (gridId) => {
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<div v-if="exercise?.text || cardResult" class="ex-header">
|
||||
@@ -1540,23 +1568,23 @@ const getGroupOutlines = (gridId) => {
|
||||
<!-- MOVE-BLOCKS -->
|
||||
<!-- ============================================================ -->
|
||||
<template v-if="cfg.exType === 'move-blocks'">
|
||||
<div ref="containerRef"
|
||||
:class="['ex-body', `layout-${cfg.layout}`]"
|
||||
@mousedown="handleGridCanvasClick">
|
||||
<div ref="containerRef" :class="['ex-body', `layout-${cfg.layout}`]" @mousedown="handleGridCanvasClick">
|
||||
|
||||
<!-- Source grids (active) -->
|
||||
<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"
|
||||
class="grid-wrapper" style="position:relative">
|
||||
<div v-for="gridDef in gridDefs.filter(g => !g.isAnswer)" :key="gridDef.id" class="grid-wrapper">
|
||||
|
||||
<!-- Grid box -->
|
||||
<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">
|
||||
<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' }"
|
||||
@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}`"
|
||||
class="grid-cell-transparent"
|
||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||
@@ -1565,67 +1593,70 @@ const getGroupOutlines = (gridId) => {
|
||||
<div class="blocks-layer-transparent"
|
||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
||||
@mousedown="handleBlockMouseDown($event, block.id, gridDef.id)"
|
||||
:class="['block-transparent',
|
||||
{ 'block-dragging': draggedGroupIds.has(block.id),
|
||||
@mousedown="handleBlockMouseDown($event, block.id, gridDef.id)" :class="['block-transparent',
|
||||
{
|
||||
'block-dragging': draggedGroupIds.has(block.id),
|
||||
'block-selected': selectedGroupIds.has(block.id) && !block.isConnected,
|
||||
'block-connected': block.isConnected,
|
||||
'no-hover': cuttingMode,
|
||||
'visually-separated': block.visuallySeparated }]"
|
||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px',
|
||||
'visually-separated': block.visuallySeparated
|
||||
}]" :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' }">
|
||||
position: 'absolute', backgroundColor: 'transparent'
|
||||
}">
|
||||
<template v-if="block.units?.length > 0">
|
||||
<div v-for="(unit, i) in block.units" :key="i"
|
||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
||||
: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' }
|
||||
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||
: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>
|
||||
</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>
|
||||
|
||||
<!-- Group outlines SVG -->
|
||||
<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 }">
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5"
|
||||
stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
class="group-outline-path" />
|
||||
</svg>
|
||||
<!-- Dashed border (active grid) -->
|
||||
<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 }">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="grid-label-below">{{ gridDef.label }}</div>
|
||||
<div v-if="gridDef.label" class="grid-label-below">{{ gridDef.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow / spacer -->
|
||||
<div class="zone-arrow">→</div>
|
||||
<div v-if="cfg.layout !== 'column'" class="zone-arrow">{{ cfg.layout === 'row-answer-bottom' ? '↓' : '→' }}
|
||||
</div>
|
||||
|
||||
<!-- Target grids -->
|
||||
<div class="zone-targets">
|
||||
<div v-for="gridDef in gridDefs.filter(g => g.isAnswer)" :key="gridDef.id"
|
||||
class="grid-wrapper" style="position:relative">
|
||||
<div v-for="gridDef in gridDefs.filter(g => g.isAnswer)" :key="gridDef.id" class="grid-wrapper">
|
||||
|
||||
<div class="grid-center inline-grid" :data-grid-index="gridDef.id"
|
||||
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
||||
<div class="grid-center inline-grid" :data-grid-index="gridDef.id" @mousedown.stop
|
||||
@mousedown.capture="handleCutModeMouseDown">
|
||||
<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' }"
|
||||
@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}`"
|
||||
class="grid-cell-transparent"
|
||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||
@@ -1634,46 +1665,47 @@ const getGroupOutlines = (gridId) => {
|
||||
<div class="blocks-layer-transparent"
|
||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
||||
@mousedown="handleBlockMouseDown($event, block.id, gridDef.id)"
|
||||
:class="['block-transparent',
|
||||
{ 'block-dragging': draggedGroupIds.has(block.id),
|
||||
@mousedown="handleBlockMouseDown($event, block.id, gridDef.id)" :class="['block-transparent',
|
||||
{
|
||||
'block-dragging': draggedGroupIds.has(block.id),
|
||||
'block-selected': selectedGroupIds.has(block.id) && !block.isConnected,
|
||||
'block-connected': block.isConnected,
|
||||
'no-hover': cuttingMode }]"
|
||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px',
|
||||
'no-hover': cuttingMode
|
||||
}]" :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' }">
|
||||
position: 'absolute', backgroundColor: 'transparent'
|
||||
}">
|
||||
<template v-if="block.units?.length > 0">
|
||||
<div v-for="(unit, i) in block.units" :key="i"
|
||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
||||
: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' }
|
||||
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||
: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>
|
||||
</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>
|
||||
|
||||
<!-- Group outlines SVG -->
|
||||
<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 }">
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5"
|
||||
stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
class="group-outline-path" />
|
||||
</svg>
|
||||
<!-- Solid border (target grid) -->
|
||||
<svg :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||
: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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="grid-label-below grid-label-below--target">{{ gridDef.label }}</div>
|
||||
</div>
|
||||
@@ -1688,28 +1720,23 @@ const getGroupOutlines = (gridId) => {
|
||||
</button>
|
||||
</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">
|
||||
<div v-for="pos in groupContext.initialPositions" :key="pos.id"
|
||||
class="drag-ghost"
|
||||
:style="{
|
||||
<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',
|
||||
}">
|
||||
<!-- render each unit inside the ghost block -->
|
||||
<template v-if="blocks.find(b => b.id === pos.id)?.fruitImage">
|
||||
<div
|
||||
v-for="(unit, ui) in blocks.find(b => b.id === pos.id)?.units ?? []"
|
||||
:key="ui"
|
||||
:style="{
|
||||
<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" :style="{
|
||||
position: 'absolute',
|
||||
left: unit.relX * ghostPos.cellSize + 'px',
|
||||
top: unit.relY * ghostPos.cellSize + 'px',
|
||||
width: 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%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
@@ -1717,15 +1744,12 @@ const getGroupOutlines = (gridId) => {
|
||||
mixBlendMode: 'multiply',
|
||||
border: 'none',
|
||||
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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(unit, ui) in blocks.find(b => b.id === pos.id)?.units ?? []"
|
||||
:key="ui"
|
||||
:style="{
|
||||
<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',
|
||||
@@ -1751,13 +1775,14 @@ const getGroupOutlines = (gridId) => {
|
||||
|
||||
<!-- Static grids -->
|
||||
<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-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' }">
|
||||
<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}`"
|
||||
class="grid-cell-transparent"
|
||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||
@@ -1765,55 +1790,50 @@ const getGroupOutlines = (gridId) => {
|
||||
</div>
|
||||
<div class="blocks-layer-transparent"
|
||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
||||
class="block-transparent no-hover"
|
||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px',
|
||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id" class="block-transparent no-hover"
|
||||
: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',
|
||||
cursor: 'default' }">
|
||||
cursor: 'default'
|
||||
}">
|
||||
<template v-if="block.units?.length > 0">
|
||||
<div v-for="(unit, i) in block.units" :key="i"
|
||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
||||
: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' }
|
||||
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||
: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>
|
||||
</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>
|
||||
|
||||
<!-- 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 }">
|
||||
<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" rx="10" ry="10" />
|
||||
</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>
|
||||
|
||||
<!-- Answer input slot -->
|
||||
<div class="input-answer-slot">
|
||||
<input
|
||||
class="answer-input"
|
||||
<input class="answer-input"
|
||||
:class="{ 'answer-input--correct': confirmResult === 'correct', 'answer-input--incorrect': confirmResult === 'incorrect' }"
|
||||
type="number" min="0" max="99"
|
||||
v-model="inputAnswer"
|
||||
placeholder="?"
|
||||
@keyup.enter="handleInputConfirm"
|
||||
type="number" min="0" max="99" v-model="inputAnswer" placeholder="?" @keyup.enter="handleInputConfirm"
|
||||
:disabled="confirmResult === 'correct'" />
|
||||
<button class="confirm-btn confirm-btn--sm"
|
||||
:class="{ 'confirm-btn--correct': confirmResult === 'correct', 'confirm-btn--incorrect': confirmResult === 'incorrect' }"
|
||||
@click="handleInputConfirm"
|
||||
:disabled="confirmResult === 'correct'">
|
||||
@click="handleInputConfirm" :disabled="confirmResult === 'correct'">
|
||||
{{ confirmResult === 'correct' ? '✓' : confirmResult === 'incorrect' ? '✗' : 'OK' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1826,18 +1846,19 @@ const getGroupOutlines = (gridId) => {
|
||||
<template v-else-if="cfg.exType === 'mark-options'">
|
||||
<div ref="containerRef" class="ex-body ex-body--mark">
|
||||
<div class="zone-sources zone-sources--row">
|
||||
<div v-for="gridDef in gridDefs" :key="gridDef.id"
|
||||
:class="['mark-option',
|
||||
{ 'mark-option--marked': markedId === gridDef.sourceId,
|
||||
<div v-for="gridDef in gridDefs" :key="gridDef.id" :class="['mark-option',
|
||||
{
|
||||
'mark-option--marked': markedId === gridDef.sourceId,
|
||||
'mark-option--correct': markedId === gridDef.sourceId && confirmResult === 'correct',
|
||||
'mark-option--incorrect': markedId === gridDef.sourceId && confirmResult === 'incorrect' }]"
|
||||
@click="handleMarkOption(gridDef.sourceId)">
|
||||
'mark-option--incorrect': markedId === gridDef.sourceId && confirmResult === 'incorrect'
|
||||
}]" @click="handleMarkOption(gridDef.sourceId)">
|
||||
|
||||
<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-transparent"
|
||||
<div :class="['grid-transparent', { 'grid--no-cell-borders': !cfg.showCellBorders }]"
|
||||
: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}`"
|
||||
class="grid-cell-transparent"
|
||||
:style="{ width: getCellSize(gridDef.id) + 'px', height: getCellSize(gridDef.id) + 'px' }"></div>
|
||||
@@ -1845,37 +1866,37 @@ const getGroupOutlines = (gridId) => {
|
||||
</div>
|
||||
<div class="blocks-layer-transparent"
|
||||
:style="{ width: getGridWidth(gridDef.id) + 'px', height: getGridHeight(gridDef.id) + 'px' }">
|
||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id"
|
||||
class="block-transparent no-hover"
|
||||
:style="{ left: block.gridX * getCellSize(gridDef.id) + 'px',
|
||||
<div v-for="block in getGridBlocks(gridDef.id)" :key="block.id" class="block-transparent no-hover"
|
||||
: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', cursor: 'pointer' }">
|
||||
position: 'absolute', backgroundColor: 'transparent', cursor: 'pointer'
|
||||
}">
|
||||
<template v-if="block.units?.length > 0">
|
||||
<div v-for="(unit, i) in block.units" :key="i"
|
||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
||||
: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' }
|
||||
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||
: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>
|
||||
</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>
|
||||
|
||||
<!-- 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 }">
|
||||
<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" rx="10" ry="10" />
|
||||
</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 -->
|
||||
<div v-if="markedId === gridDef.sourceId" class="mark-x">✕</div>
|
||||
</div>
|
||||
@@ -1892,15 +1913,18 @@ const getGroupOutlines = (gridId) => {
|
||||
|
||||
<!-- Left column: source grids at even positions (0, 2, 4…) -->
|
||||
<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"
|
||||
:style="cfg.gridPadding ? { padding: cfg.gridPadding + 'px', boxSizing: 'content-box' } : {}"
|
||||
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
||||
<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' }"
|
||||
@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}`"
|
||||
class="grid-cell-transparent"
|
||||
: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' }">
|
||||
<template v-if="block.units?.length > 0">
|
||||
<div v-for="(unit, i) in block.units" :key="i"
|
||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
||||
: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' }">
|
||||
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||
: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>
|
||||
</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>
|
||||
<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 }">
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
class="group-outline-path" />
|
||||
</svg>
|
||||
<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 }">
|
||||
<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" />
|
||||
</svg>
|
||||
<div class="grid-label-below">{{ gridDef.label }}</div>
|
||||
</div>
|
||||
<div v-if="gridDef.label" class="grid-label-below">{{ gridDef.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: target grid -->
|
||||
<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"
|
||||
:style="cfg.gridPadding ? { padding: cfg.gridPadding + 'px', boxSizing: 'content-box' } : {}"
|
||||
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
||||
<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' }"
|
||||
@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}`"
|
||||
class="grid-cell-transparent"
|
||||
: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' }">
|
||||
<template v-if="block.units?.length > 0">
|
||||
<div v-for="(unit, i) in block.units" :key="i"
|
||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
||||
: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' }">
|
||||
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||
: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>
|
||||
</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>
|
||||
<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 }">
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
class="group-outline-path" />
|
||||
</svg>
|
||||
<svg :width="getGridWidth(gridDef.id)" :height="getGridHeight(gridDef.id)"
|
||||
: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"
|
||||
fill="none" stroke="#6366f1" stroke-width="2.5" rx="10" ry="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="grid-label-below grid-label-below--target">{{ gridDef.label }}</div>
|
||||
</div>
|
||||
<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…) -->
|
||||
<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"
|
||||
:style="cfg.gridPadding ? { padding: cfg.gridPadding + 'px', boxSizing: 'content-box' } : {}"
|
||||
@mousedown.stop @mousedown.capture="handleCutModeMouseDown">
|
||||
<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' }"
|
||||
@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}`"
|
||||
class="grid-cell-transparent"
|
||||
: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' }">
|
||||
<template v-if="block.units?.length > 0">
|
||||
<div v-for="(unit, i) in block.units" :key="i"
|
||||
:class="['unit-cube', { 'unit-cube--fruit': block.fruitImage }]"
|
||||
: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' }">
|
||||
:class="['unit-cube', { 'unit-cube--image': block.blockImage }]"
|
||||
: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>
|
||||
</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>
|
||||
<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 }">
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD"
|
||||
fill="none" stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" class="group-outline-path" />
|
||||
<path v-for="(pathD, i) in getGroupOutlines(gridDef.id)" :key="i" :d="pathD" fill="none"
|
||||
stroke="rgba(99,102,241,0.85)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
class="group-outline-path" />
|
||||
</svg>
|
||||
<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 }">
|
||||
<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" />
|
||||
</svg>
|
||||
<div class="grid-label-below">{{ gridDef.label }}</div>
|
||||
</div>
|
||||
<div v-if="gridDef.label" class="grid-label-below">{{ gridDef.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2042,9 +2081,9 @@ const getGroupOutlines = (gridId) => {
|
||||
<template v-if="ghostPos.visible && draggedBlockId !== null && groupContext !== null">
|
||||
<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' }">
|
||||
<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"
|
||||
: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>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -2061,7 +2100,6 @@ const getGroupOutlines = (gridId) => {
|
||||
<div class="ex-footer">
|
||||
<button class="btn-reset" @click="handleReset">↺ Reset</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2169,13 +2207,19 @@ const getGroupOutlines = (gridId) => {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.block-transparent {
|
||||
@@ -2196,6 +2240,14 @@ const getGroupOutlines = (gridId) => {
|
||||
-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 {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
@@ -2203,18 +2255,32 @@ const getGroupOutlines = (gridId) => {
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.65rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.4);
|
||||
transition: background-color 0.2s 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);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
text-shadow: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.unit-cube::before,
|
||||
@@ -2222,7 +2288,8 @@ const getGroupOutlines = (gridId) => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.unit-cube--fruit {
|
||||
/* Fruit blocks: fill full cell, no 3D shading */
|
||||
.unit-cube--image {
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
@@ -2230,6 +2297,7 @@ const getGroupOutlines = (gridId) => {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.block-transparent.block-connected {
|
||||
@@ -2584,8 +2652,13 @@ const getGroupOutlines = (gridId) => {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.zone-sources--row { flex-direction: row; }
|
||||
.zone-sources--col { flex-direction: column; }
|
||||
.zone-sources--row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.zone-sources--col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.zone-targets {
|
||||
display: flex;
|
||||
@@ -2675,7 +2748,10 @@ const getGroupOutlines = (gridId) => {
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-color: #6366f1;
|
||||
@@ -2707,14 +2783,15 @@ const getGroupOutlines = (gridId) => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
border: 2px solid #e5e7eb;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
user-select: none;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.mark-option:hover {
|
||||
@@ -2748,8 +2825,13 @@ const getGroupOutlines = (gridId) => {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mark-option--correct .mark-x { color: rgba(16, 185, 129, 0.85); }
|
||||
.mark-option--incorrect .mark-x { color: rgba(239, 68, 68, 0.85); }
|
||||
.mark-option--correct .mark-x {
|
||||
color: rgba(16, 185, 129, 0.85);
|
||||
}
|
||||
|
||||
.mark-option--incorrect .mark-x {
|
||||
color: rgba(239, 68, 68, 0.85);
|
||||
}
|
||||
|
||||
|
||||
/* ===== SIDES LAYOUT ===== */
|
||||
@@ -2769,8 +2851,18 @@ const getGroupOutlines = (gridId) => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sides-col--left { flex: 1; align-items: flex-end; }
|
||||
.sides-col--center { flex-shrink: 0; align-items: center; }
|
||||
.sides-col--right { flex: 1; align-items: flex-start; }
|
||||
.sides-col--left {
|
||||
flex: 1;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.sides-col--center {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sides-col--right {
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</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