@edc-motor/ui 0.4.26 → 0.4.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edc-motor/ui",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
4
4
  "description": "EdC Motor — componentes públicos Vue 3 + tokens SCSS para webs de juegos de mesa (paquete fuente: lo compila el consumidor con Vite)",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -33,15 +33,21 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "axios": "^1.7.0",
37
- "@tiptap/vue-3": "^2.11.0",
38
- "@tiptap/starter-kit": "^2.11.0",
39
36
  "@tiptap/extension-image": "^2.11.0",
40
- "@tiptap/pm": "^2.11.0"
37
+ "@tiptap/extension-link": "^2.11.0",
38
+ "@tiptap/extension-table": "^2.11.0",
39
+ "@tiptap/extension-table-cell": "^2.11.0",
40
+ "@tiptap/extension-table-header": "^2.11.0",
41
+ "@tiptap/extension-table-row": "^2.11.0",
42
+ "@tiptap/extension-underline": "^2.11.0",
43
+ "@tiptap/pm": "^2.11.0",
44
+ "@tiptap/starter-kit": "^2.11.0",
45
+ "@tiptap/vue-3": "^2.11.0",
46
+ "axios": "^1.7.0"
41
47
  },
42
48
  "peerDependencies": {
43
- "vue": "^3.5.0",
44
49
  "@lucide/vue": "^1.0.0",
50
+ "vue": "^3.5.0",
45
51
  "vue-router": "^5.0.0"
46
52
  }
47
53
  }
@@ -1,7 +1,7 @@
1
1
  @use "tokens" as *;
2
2
 
3
3
  // Bloque `related` (BlockRelated.vue): cabecera en rejilla (títulos + botón
4
- // al índice a la derecha en ≥sm, patrón del relateds de CDL) sobre una
4
+ // al índice a la derecha en ≥600, patrón del relateds de CDL) sobre una
5
5
  // PreviewGrid compacta.
6
6
  .block--related {
7
7
  .block__related-header {
@@ -9,7 +9,11 @@
9
9
  grid-template-columns: 1fr;
10
10
  gap: $space-4;
11
11
 
12
- @media (min-width: $bp-sm) {
12
+ // 600px es un umbral PROPIO de esta cabecera (sin token: no encaja en
13
+ // $bp-sm/$bp-md) — por debajo, título + subtítulo + botón caen en TRES
14
+ // filas (una columna); container query sobre `content` (el ancho real
15
+ // del bloque), coherente con el resto de bloques con imagen.
16
+ @container content (min-width: 600px) {
13
17
  grid-template-columns: 1fr auto;
14
18
  align-items: center;
15
19
  }
@@ -24,14 +28,14 @@
24
28
  .block__related-button {
25
29
  justify-self: center; // estrecho: los botones de bloque van centrados
26
30
 
27
- @media (min-width: $bp-sm) {
31
+ @container content (min-width: 600px) {
28
32
  justify-self: end;
29
33
  }
30
34
  }
31
35
 
32
36
  // Centrado/derecha del campo común `align`: la cabecera acompaña.
33
37
  &.block--align-center .block__related-header {
34
- @media (min-width: $bp-sm) {
38
+ @container content (min-width: 600px) {
35
39
  grid-template-columns: 1fr;
36
40
  }
37
41
  }
@@ -2,8 +2,14 @@
2
2
 
3
3
  // Bloques públicos del CRM (doc 03): estilos base, cada juego los ajusta.
4
4
  .block {
5
- // Aire vertical entre bloques (CDL usa 4rem; aquí algo más contenido).
5
+ // Aire vertical entre bloques (CDL usa 4rem; aquí algo más contenido). En
6
+ // estrecho (contenedor `content') baja un poco: los bloques respiran
7
+ // menos apretados en pantallas pequeñas.
6
8
  padding-block: $space-8 + $space-6; // 56px
9
+
10
+ @container content (max-width: #{$bp-sm - 1px}) {
11
+ padding-block: $space-8 + $space-2; // 40px
12
+ }
7
13
  background: var(--block-bg, transparent);
8
14
 
9
15
  &__inner {
@@ -15,22 +21,46 @@
15
21
  }
16
22
 
17
23
  // Anchura del contenido (campo común `width`): coherente entre bloques.
18
- &--w-narrow .block__inner { max-width: 800px; }
24
+ &--w-narrow .block__inner { max-width: 880px; }
19
25
  &--w-full .block__inner { max-width: none; padding-inline: $space-6; }
20
26
 
21
- // Tipografía de bloque, escalada ~×1.125 sobre la original para que el
22
- // texto BASE sea de 18px: texto $fs-18, subtítulo 22, título $fs-32 (la
23
- // cabecera sube a 36/28). Los títulos nunca se justifican.
27
+ // Las tarjetas (CTA, tarjeta de texto y cita) son algo más angostas que el
28
+ // resto en cada preset (wide/narrow), con la diferencia PROPORCIONAL (no
29
+ // un recorte fijo que desaparece de golpe): min(tope, 90%) 90% ≈ la
30
+ // relación 1080/1200 del preset ancho, así que en pantallas grandes manda
31
+ // el tope (120px/100px más angosto que el resto) y, según se estrecha,
32
+ // manda el porcentaje y la diferencia decrece sin saltos. "Ancho
33
+ // completo" no lleva tope: full es full.
34
+ &--cta.block--w-wide .block__inner,
35
+ &--text-card.block--w-wide .block__inner,
36
+ &--quote.block--w-wide .block__inner { max-width: min(1080px, 90%); }
37
+ &--cta.block--w-narrow .block__inner,
38
+ &--text-card.block--w-narrow .block__inner,
39
+ &--quote.block--w-narrow .block__inner { max-width: min(780px, 90%); }
40
+
41
+ // Tipografía de bloque: texto BASE $fs-16 (el mismo cuerpo que el wysiwyg
42
+ // por defecto — sin override aquí), subtítulo $fs-24 (entre el título y
43
+ // la base), título $fs-32 (la cabecera sube a 40/28). Los títulos nunca
44
+ // se justifican.
24
45
  // width 100%: en grids con justify-items start (el cuerpo del CTA) el
25
46
  // elemento encoge a su contenido y el text-align no se vería.
26
47
  &__title { margin: 0; font-size: $fs-32; width: 100%; }
27
48
  // pre-line: el subtítulo es un textarea — sus saltos de línea cuentan.
28
- &__subtitle { margin: 0; color: $text-2; font-size: 22px; white-space: pre-line; width: 100%; }
29
- &__text { color: $text-1; font-size: $fs-18; }
49
+ &__subtitle { margin: 0; color: $text-2; font-size: $fs-24; white-space: pre-line; width: 100%; }
50
+ &__text { color: $text-1; font-size: $fs-16; }
30
51
 
31
- // El wysiwyg fija su propio cuerpo ($fs-16): dentro de un bloque manda la
32
- // base de bloque (18px). La cita lo re-sube luego a su tamaño.
33
- .rich-content { font-size: $fs-18; }
52
+ // Alineación del bloque (campo común `align`): por CLASE (BlockShell ya
53
+ // no la mete como style en línea) para poder pisarla en estrecho.
54
+ &--align-left { text-align: left; }
55
+ &--align-center { text-align: center; }
56
+ &--align-right { text-align: right; }
57
+ &--align-justify { text-align: justify; }
58
+
59
+ // Justificado, a la IZQUIERDA por debajo de $bp-sm — mismo criterio (y
60
+ // mismo mecanismo, @media de viewport) que título/subtítulo, más abajo.
61
+ @media (max-width: $bp-sm - 1px) {
62
+ &--align-justify { text-align: left; }
63
+ }
34
64
 
35
65
  &--align-justify &__title,
36
66
  &--align-justify &__subtitle { text-align: left; }
@@ -88,7 +118,7 @@
88
118
  .edc-button { justify-self: start; text-decoration: none; }
89
119
  }
90
120
 
91
- &--header .block__title { font-size: $fs-36; }
121
+ &--header .block__title { font-size: $fs-40; }
92
122
  &--header .block__subtitle { font-size: $fs-28; }
93
123
 
94
124
  &__media-layout { display: grid; gap: $space-4; }
@@ -122,8 +152,11 @@
122
152
  }
123
153
  }
124
154
 
125
- // Marco de la imagen en columnas: el ALTO lo marca el texto de al lado
126
- // (la imagen absoluta no aporta altura) y el modo de escalado hace el resto.
155
+ // Marco de la imagen en columnas: por defecto (cubrir/rellenar) el ALTO
156
+ // lo marca el texto de al lado (la imagen absoluta no aporta altura) y el
157
+ // modo de escalado hace el resto — object-position top: con el marco
158
+ // estirado, "cubrir" centraba la imagen en vertical, SIEMPRE anclada
159
+ // arriba.
127
160
  &__image-frame {
128
161
  position: relative;
129
162
  min-height: 200px; // que no colapse con textos muy cortos
@@ -136,13 +169,30 @@
136
169
  max-width: none;
137
170
  }
138
171
  }
139
-
140
- // object-position top: con el marco estirado a la altura del texto,
141
- // "contain" centraba la imagen en vertical — SIEMPRE anclada arriba.
142
- &--fit-contain &__image-frame .block__image { object-fit: contain; object-position: top; }
143
172
  &--fit-cover &__image-frame .block__image { object-fit: cover; object-position: top; }
144
173
  &--fit-fill &__image-frame .block__image { object-fit: fill; }
145
174
 
175
+ // "Contener" es la excepción: la imagen respeta su tamaño NATURAL — ancho
176
+ // de columna, alto según su propia proporción — sin ampliarse ni
177
+ // encogerse para casar con el marco (si es más alta que el texto la fila
178
+ // crece; si es más baja, queda aire debajo). Por eso escapa del marco
179
+ // absoluto: vuelve al flujo normal, anclada arriba (align-self) del grid
180
+ // de columnas.
181
+ &--fit-contain &__image-frame {
182
+ @container content (min-width: #{$bp-md}) {
183
+ position: static;
184
+ min-height: 0;
185
+ align-self: start;
186
+
187
+ .block__image {
188
+ position: static;
189
+ width: 100%;
190
+ height: auto;
191
+ max-width: none;
192
+ }
193
+ }
194
+ }
195
+
146
196
  // En una columna (vertical, < 768) la imagen vuelve al flujo normal.
147
197
  @container content (max-width: #{$bp-md}) {
148
198
  &__image-frame {
@@ -175,10 +225,9 @@
175
225
  @container content (min-width: #{$bp-md}) {
176
226
  float: left;
177
227
  max-width: 50%;
178
- margin-right: $space-4;
179
- // Anclada ARRIBA mientras el texto la rodea (sin hueco previo ni
180
- // alineación heredada que la descuelgue).
181
- margin-top: 0;
228
+ // Respiro de $space-4 (16px) por arriba, abajo y hacia el texto
229
+ // (derecha); pegada a su lado exterior (izquierda, sin margen).
230
+ margin: $space-4 $space-4 $space-4 0;
182
231
  vertical-align: top;
183
232
  }
184
233
  }
@@ -186,8 +235,7 @@
186
235
  @container content (min-width: #{$bp-md}) {
187
236
  float: right;
188
237
  max-width: 50%;
189
- margin-left: $space-4;
190
- margin-top: 0;
238
+ margin: $space-4 0 $space-4 $space-4;
191
239
  vertical-align: top;
192
240
  }
193
241
  }
@@ -234,10 +282,10 @@
234
282
  }
235
283
 
236
284
  // La imagen del CTA sangra hasta el borde de la tarjeta según su posición:
237
- // en columnas toca abajo y su lateral (título y subtítulo van SIEMPRE a
238
- // ancho completo por encima del grid; solo si la tarjeta ABRE con la
239
- // imagen sangra también por arriba); arriba/abajo toca los laterales.
240
- // Esquinas siguiendo el radio de la tarjeta.
285
+ // en columnas toca TODA la altura (arriba, abajo y su lateral título y
286
+ // subtítulo viven dentro de `.block__cta-body`, junto al texto, en la
287
+ // columna de contenido); arriba/abajo toca los laterales. Esquinas
288
+ // siguiendo el radio de la tarjeta.
241
289
  // Las tarjetas del CTA y de la tarjeta de texto llevan un halo SUTIL del
242
290
  // acento, igual por los cuatro bordes, además de su sombra normal.
243
291
  &--cta .block__card,
@@ -256,27 +304,13 @@
256
304
  }
257
305
  &--cta.block--image-right .block__media-layout .block__image-frame {
258
306
  @container content (min-width: #{$bp-md}) {
259
- margin: 0 (-$space-8) (-$space-8) 0;
260
- border-radius: 0 0 $radius-lg 0;
261
- }
262
- }
263
- &--cta.block--image-left .block__media-layout .block__image-frame {
264
- @container content (min-width: #{$bp-md}) {
265
- margin: 0 0 (-$space-8) (-$space-8);
266
- border-radius: 0 0 0 $radius-lg;
267
- }
268
- }
269
- // Sin nada por encima (el media-layout abre la tarjeta): sangra también
270
- // por arriba, con el radio completo de su lateral.
271
- &--cta.block--image-right .block__card > .block__media-layout:first-child .block__image-frame {
272
- @container content (min-width: #{$bp-md}) {
273
- margin-top: -$space-8;
307
+ margin: (-$space-8) (-$space-8) (-$space-8) 0;
274
308
  border-radius: 0 $radius-lg $radius-lg 0;
275
309
  }
276
310
  }
277
- &--cta.block--image-left .block__card > .block__media-layout:first-child .block__image-frame {
311
+ &--cta.block--image-left .block__media-layout .block__image-frame {
278
312
  @container content (min-width: #{$bp-md}) {
279
- margin-top: -$space-8;
313
+ margin: (-$space-8) 0 (-$space-8) (-$space-8);
280
314
  border-radius: $radius-lg 0 0 $radius-lg;
281
315
  }
282
316
  }
@@ -343,14 +377,14 @@
343
377
  }
344
378
  }
345
379
 
346
- // Cita: sin adorno de borde, en GRANDE ($fs-36, la escala de bloque
347
- // subida ~×1.125) y del color de acento.
380
+ // Cita: sin adorno de borde, en GRANDE ($fs-32, el mismo tamaño que un
381
+ // título de bloque) y del color de acento.
348
382
  &--quote .block__quote {
349
383
  margin: 0;
350
384
  // Fuente "especial" de la configuración de la web (doc 10); las fuentes
351
385
  // decorativas piden un cuerpo mayor.
352
386
  font-family: var(--font-special, inherit);
353
- font-size: $fs-36;
387
+ font-size: $fs-32;
354
388
  font-style: italic;
355
389
  font-weight: $k-fw-semibold;
356
390
  color: $accent-500;
@@ -365,7 +399,7 @@
365
399
  // El autor no hereda la fuente especial de la cita.
366
400
  font-family: var(--font-body, inherit);
367
401
  font-style: italic;
368
- font-size: $fs-16;
402
+ font-size: $fs-14;
369
403
 
370
404
  &--left { text-align: left; }
371
405
  &--center { text-align: center; }
@@ -396,11 +430,11 @@
396
430
  font-variant-numeric: tabular-nums;
397
431
  }
398
432
 
399
- // Tamaño por nivel, en la escala de bloque (~×1.125): 27 2522
400
- // (el 3 agrupa "3 o más").
401
- .block__index-level-1 { font-size: 27px; }
402
- .block__index-level-2 { font-size: 25px; }
403
- .block__index-level-3 { font-size: 22px; }
433
+ // Tamaño por nivel: 24 22 (no hay token entre 20 y 24) 20 (el 3
434
+ // agrupa "3 o más").
435
+ .block__index-level-1 { font-size: $fs-24; }
436
+ .block__index-level-2 { font-size: 22px; }
437
+ .block__index-level-3 { font-size: $fs-20; }
404
438
 
405
439
  // Entradas anidadas: sangría POR NIVEL (profundidad real, sin límite) —
406
440
  // `--depth` lo pone BlockIndex.vue en línea; el tamaño de letra sí se
@@ -421,7 +455,6 @@
421
455
 
422
456
  .block__faq-question {
423
457
  padding: $space-3 $space-4;
424
- font-size: $fs-18;
425
458
  font-weight: 600;
426
459
  cursor: pointer;
427
460
 
@@ -447,7 +480,7 @@
447
480
  &--large { padding: $space-4 $space-8; }
448
481
  border: 1px solid $accent-500;
449
482
  border-radius: $radius-md;
450
- font-size: $fs-20;
483
+ font-size: $fs-18;
451
484
  font-weight: 600;
452
485
  text-decoration: none;
453
486
  cursor: pointer;
@@ -1,5 +1,10 @@
1
1
  @use "tokens" as *;
2
2
 
3
+ // Chevron de lucide (`chevron-down`) como máscara: el color lo pone
4
+ // `background-color` (mask solo usa el canal alfa del SVG), así que
5
+ // funciona igual en claro/oscuro y en cualquier tema de juego.
6
+ $icon-chevron-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
7
+
3
8
  // Estilo compartido de los elementos de formulario (portado de kontuan):
4
9
  // BaseInput, BaseTextarea, BaseSelect y TranslatableInput.
5
10
  .form-field {
@@ -49,6 +54,9 @@
49
54
  resize: vertical;
50
55
  }
51
56
 
57
+ // Indicador del select: chevron (lucide `chevron-down`) pintado por
58
+ // máscara — así toma el color de $text-3 (claro/oscuro y temas de
59
+ // juego) sin depender de un SVG con el color grabado.
52
60
  &__select-wrapper {
53
61
  position: relative;
54
62
  &::after {
@@ -56,12 +64,18 @@
56
64
  position: absolute;
57
65
  right: $input-padding-x;
58
66
  top: 50%;
67
+ width: 16px;
68
+ height: 16px;
59
69
  transform: translateY(-50%);
60
- width: 0;
61
- height: 0;
62
- border-left: 5px solid transparent;
63
- border-right: 5px solid transparent;
64
- border-top: 5px solid $text-3;
70
+ background-color: $text-3;
71
+ -webkit-mask-image: $icon-chevron-down;
72
+ mask-image: $icon-chevron-down;
73
+ -webkit-mask-repeat: no-repeat;
74
+ mask-repeat: no-repeat;
75
+ -webkit-mask-position: center;
76
+ mask-position: center;
77
+ -webkit-mask-size: contain;
78
+ mask-size: contain;
65
79
  pointer-events: none;
66
80
  }
67
81
  }
@@ -26,6 +26,9 @@
26
26
  &--sm { max-width: 380px; }
27
27
  &--md { max-width: 560px; }
28
28
  &--lg { max-width: 820px; }
29
+ // Formularios con más columnas (el modal de bloque, EditModal): más ancho
30
+ // que el resto, pero sin llegar a ocupar toda la pantalla.
31
+ &--wide { max-width: 940px; }
29
32
 
30
33
  &__header {
31
34
  flex-shrink: 0;
@@ -50,11 +53,16 @@
50
53
  }
51
54
 
52
55
  // El cuerpo es la única zona que scrollea (cabecera y pie quedan fijos).
56
+ // Contenedor con nombre (container query, no viewport): los formularios de
57
+ // dentro (SchemaFields/PageBlocks) apilan sus filas de dos columnas según
58
+ // el ANCHO REAL del modal, no el del viewport.
53
59
  &__body {
54
60
  flex: 1 1 auto;
55
61
  min-height: 0;
56
62
  overflow-y: auto;
57
63
  padding: $space-5;
64
+ container-type: inline-size;
65
+ container-name: modal-body;
58
66
  }
59
67
 
60
68
  &__footer {
@@ -34,12 +34,13 @@
34
34
  > :first-child { margin-block-start: 0; }
35
35
  > :last-child { margin-block-end: 0; }
36
36
 
37
- // Escala del wysiwyg: h2 28 · h3 24 · h4 20 · h5 18 · h6 16.
38
- h2 { font-size: $fs-28; }
37
+ // Escala del wysiwyg: h2 32 (el mismo tamaño que un título de bloque) ·
38
+ // h3 24 · h4 22 (no hay token entre 20 y 24) · h5 20 · h6 18.
39
+ h2 { font-size: $fs-32; }
39
40
  h3 { font-size: $fs-24; }
40
- h4 { font-size: $fs-20; }
41
- h5 { font-size: $fs-18; }
42
- h6 { font-size: $fs-16; }
41
+ h4 { font-size: 22px; }
42
+ h5 { font-size: $fs-20; }
43
+ h6 { font-size: $fs-18; }
43
44
 
44
45
  ul,
45
46
  ol {
@@ -48,6 +49,15 @@
48
49
  li + li { margin-block-start: $space-1; }
49
50
  }
50
51
 
52
+ // Cita: borde a la izquierda + texto atenuado en cursiva (mismo criterio
53
+ // que la vista de edición de RichTextInput).
54
+ blockquote {
55
+ padding-left: $space-4;
56
+ border-left: 3px solid $border-strong;
57
+ color: $text-2;
58
+ font-style: italic;
59
+ }
60
+
51
61
  // Iconos del juego insertados en línea por el editor (<img class="rt-icon">).
52
62
  img.rt-icon {
53
63
  display: inline-block;
@@ -56,4 +66,29 @@
56
66
  vertical-align: -0.24em;
57
67
  margin: 0 1px;
58
68
  }
69
+
70
+ // Tabla del wysiwyg: ancho completo, bordes finos y cabecera destacada.
71
+ // `display: block` + `overflow-x: auto` en la propia tabla (no en un
72
+ // envoltorio aparte: menos marcado extra) evita que desborde en estrecho
73
+ // sin perder el ancho completo cuando sí cabe. Las clases de fila que
74
+ // use cada juego (p. ej. `green-bg`) las estila el JUEGO, no el motor.
75
+ table {
76
+ display: block;
77
+ overflow-x: auto;
78
+ width: 100%;
79
+ margin-block: $space-3;
80
+ border-collapse: collapse;
81
+
82
+ td,
83
+ th {
84
+ padding: $space-2 $space-3;
85
+ border: 1px solid $border;
86
+ text-align: left;
87
+ }
88
+
89
+ th {
90
+ font-weight: $k-fw-semibold;
91
+ background: $surface-2;
92
+ }
93
+ }
59
94
  }
@@ -69,9 +69,49 @@
69
69
  outline: none;
70
70
  }
71
71
 
72
- // Selector de iconos del juego.
72
+ // Selector de iconos del juego (y el mismo contenedor sirve de anclaje
73
+ // para el mini-popover del enlace).
73
74
  &__picker { position: relative; display: inline-flex; }
74
75
 
76
+ // Mini-popover del enlace: input de URL + botón de aplicar, misma
77
+ // posición/estética que el panel de iconos.
78
+ &__link-popover {
79
+ position: absolute;
80
+ top: calc(100% + 4px);
81
+ left: 0;
82
+ z-index: 100;
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 2px;
86
+ // `width: max-content` (no simplemente `auto`): dentro de un ancla tan
87
+ // angosta como el propio botón (`.rich-text__picker`), el algoritmo de
88
+ // shrink-to-fit de un absoluto con solo `left` fijado lo recorta al
89
+ // ancho del ancla — el input de 220px quedaba aplastado a ~35px.
90
+ width: max-content;
91
+ padding: $space-1;
92
+ background: $surface;
93
+ border: 1px solid $border;
94
+ border-radius: $radius-md;
95
+ box-shadow: $shadow-md;
96
+ }
97
+
98
+ &__link-input {
99
+ width: 220px;
100
+ height: 30px;
101
+ padding: 0 $space-2;
102
+ border: 1px solid $input-border;
103
+ border-radius: $radius-sm;
104
+ background: $input-bg;
105
+ color: $input-text;
106
+ font-size: $fs-13;
107
+
108
+ &:focus {
109
+ outline: none;
110
+ border-color: $input-border-focus;
111
+ box-shadow: $input-focus-ring;
112
+ }
113
+ }
114
+
75
115
  &__icons {
76
116
  position: absolute;
77
117
  top: calc(100% + 4px);
@@ -118,10 +158,44 @@
118
158
 
119
159
  > * + * { margin-top: 0.6em; }
120
160
  h2 { font-size: $fs-18; font-weight: $k-fw-semibold; }
161
+ h3 { font-size: $fs-16; font-weight: $k-fw-semibold; }
162
+ h4, h5 { font-size: $fs-14; font-weight: $k-fw-semibold; }
121
163
  ul, ol { padding-left: 1.4em; }
122
164
  ul { list-style: disc; }
123
165
  ol { list-style: decimal; }
124
166
  a { color: $accent-500; text-decoration: underline; }
167
+ blockquote {
168
+ padding-left: $space-3;
169
+ border-left: 3px solid $border-strong;
170
+ color: $text-2;
171
+ font-style: italic;
172
+ }
173
+
174
+ // Tabla: visible y editable (bordes finos, cabecera destacada). Sin
175
+ // resize de columnas (Table.configure resizable:false), así que no
176
+ // hace falta CSS para la asa.
177
+ table {
178
+ width: 100%;
179
+ border-collapse: collapse;
180
+ table-layout: fixed;
181
+
182
+ td, th {
183
+ min-width: 1em;
184
+ padding: $space-1 $space-2;
185
+ border: 1px solid $border-strong;
186
+ vertical-align: top;
187
+ }
188
+
189
+ th {
190
+ background: $surface-2;
191
+ font-weight: $k-fw-semibold;
192
+ text-align: left;
193
+ }
194
+
195
+ .selectedCell {
196
+ background: color-mix(in srgb, $accent-500 12%, transparent);
197
+ }
198
+ }
125
199
 
126
200
  // Iconos insertados: en línea con el texto.
127
201
  img.rt-icon {
@@ -27,16 +27,24 @@ const layoutStyle = computed(() => {
27
27
  ]"
28
28
  >
29
29
  <div class="block__card">
30
- <!-- Título y subtítulo SIEMPRE a ancho completo, encima del grid: el
31
- reparto en columnas es solo entre la imagen y el contenido. -->
32
- <h2 v-if="settings.title" class="block__title">{{ settings.title }}</h2>
33
- <p v-if="settings.subtitle" class="block__subtitle">{{ settings.subtitle }}</p>
30
+ <!-- En columnas, título y subtítulo viajan a la columna de texto para
31
+ que la imagen pueda sangrar a TODA la altura de la tarjeta (a
32
+ todo ancho, en todos los anchos de pantalla: es una decisión de
33
+ plantilla por settings, no por el ancho del contenedor). -->
34
+ <template v-if="!inColumns">
35
+ <h2 v-if="settings.title" class="block__title">{{ settings.title }}</h2>
36
+ <p v-if="settings.subtitle" class="block__subtitle">{{ settings.subtitle }}</p>
37
+ </template>
34
38
  <div class="block__media-layout" :style="layoutStyle">
35
39
  <span v-if="settings.image && inColumns" class="block__image-frame">
36
40
  <img class="block__image" :src="String(settings.image)" alt="" />
37
41
  </span>
38
42
  <img v-else-if="settings.image" class="block__image" :src="String(settings.image)" alt="" />
39
43
  <div class="block__cta-body">
44
+ <template v-if="inColumns">
45
+ <h2 v-if="settings.title" class="block__title">{{ settings.title }}</h2>
46
+ <p v-if="settings.subtitle" class="block__subtitle">{{ settings.subtitle }}</p>
47
+ </template>
40
48
  <div v-if="settings.body" class="block__text rich-content" v-html="settings.body" />
41
49
  <!-- Alineación propia (button_align; en estrecho el CSS centra
42
50
  siempre) y tamaño grande opcional (button_large) -->
@@ -19,13 +19,15 @@ const headingAlign = (key: 'title_align' | 'subtitle_align', prefix: string) =>
19
19
  return value && value !== 'inherit' ? `block--${prefix}-${value}` : ''
20
20
  }
21
21
 
22
+ // La alineación va por CLASE (block--align-*, _blocks.scss), no por style en
23
+ // línea: un style inline no se puede pisar desde CSS (bloqueaba el cambio a
24
+ // izquierda del justificado en estrecho, DC-03 ampliado).
22
25
  const style = computed<CSSProperties>(() => {
23
26
  const background = props.settings.background as string | undefined
24
27
  return {
25
28
  '--block-bg': background
26
29
  ? `color-mix(in srgb, ${background} var(--block-tint, 15%), transparent)`
27
30
  : 'transparent',
28
- textAlign: ((props.settings.align as string) || 'justify') as CSSProperties['textAlign'],
29
31
  }
30
32
  })
31
33
  </script>
@@ -33,7 +35,12 @@ const style = computed<CSSProperties>(() => {
33
35
  <template>
34
36
  <section
35
37
  class="block"
36
- :class="[width, align, headingAlign('title_align', 'title'), headingAlign('subtitle_align', 'subtitle')]"
38
+ :class="[
39
+ width,
40
+ align,
41
+ headingAlign('title_align', 'title'),
42
+ headingAlign('subtitle_align', 'subtitle'),
43
+ ]"
37
44
  :style="style"
38
45
  >
39
46
  <div class="block__inner">
@@ -11,7 +11,7 @@ const props = withDefaults(
11
11
  defineProps<{
12
12
  modelValue: boolean
13
13
  title?: string
14
- size?: 'sm' | 'md' | 'lg'
14
+ size?: 'sm' | 'md' | 'lg' | 'wide'
15
15
  closeLabel?: string
16
16
  }>(),
17
17
  { size: 'md', closeLabel: 'Cerrar' },
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  import BaseModal from './BaseModal.vue'
3
4
  import { Save, X } from '@lucide/vue'
4
5
  import BaseButton from './BaseButton.vue'
@@ -11,7 +12,8 @@ const props = withDefaults(
11
12
  defineProps<{
12
13
  modelValue: boolean
13
14
  title: string
14
- size?: 'sm' | 'md' | 'lg'
15
+ /** 'wide' para formularios con más columnas (el modal de bloque). */
16
+ size?: 'normal' | 'wide'
15
17
  loading?: boolean
16
18
  submitLabel?: string
17
19
  cancelLabel?: string
@@ -20,7 +22,7 @@ const props = withDefaults(
20
22
  localeSwitchLabel?: string
21
23
  }>(),
22
24
  {
23
- size: 'md',
25
+ size: 'normal',
24
26
  loading: false,
25
27
  submitLabel: 'Guardar',
26
28
  cancelLabel: 'Cancelar',
@@ -29,6 +31,11 @@ const props = withDefaults(
29
31
  },
30
32
  )
31
33
 
34
+ // EditModal tiene su propia escala de tamaño (normal/wide, sobre el
35
+ // contenido del FORMULARIO); BaseModal sigue con la suya (sm/md/lg/wide,
36
+ // genérica) — normal se apoya en su 'md' por defecto.
37
+ const modalSize = computed(() => (props.size === 'wide' ? 'wide' : 'md'))
38
+
32
39
  const emit = defineEmits<{ 'update:modelValue': [boolean]; submit: [] }>()
33
40
 
34
41
  // Locale global del formulario: los campos traducibles del slot (Translatable*
@@ -46,7 +53,7 @@ function close() {
46
53
  <BaseModal
47
54
  :model-value="modelValue"
48
55
  :title="title"
49
- :size="size"
56
+ :size="modalSize"
50
57
  @update:model-value="(v) => !loading && emit('update:modelValue', v)"
51
58
  >
52
59
  <!-- Cabecera propia: título + selector de locale global (si hay campos
@@ -1,15 +1,37 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
2
+ import { computed, ref, watch, onBeforeUnmount, onMounted, nextTick } from 'vue'
3
3
  import { useEditor, EditorContent } from '@tiptap/vue-3'
4
4
  import StarterKit from '@tiptap/starter-kit'
5
5
  import Image from '@tiptap/extension-image'
6
+ import UnderlineExtension from '@tiptap/extension-underline'
7
+ import LinkExtension from '@tiptap/extension-link'
8
+ import TableExtension from '@tiptap/extension-table'
9
+ import TableRow from '@tiptap/extension-table-row'
10
+ import TableHeader from '@tiptap/extension-table-header'
11
+ import TableCell from '@tiptap/extension-table-cell'
6
12
  import {
7
13
  Bold,
8
14
  Italic,
9
15
  Strikethrough,
16
+ Underline as UnderlineIcon,
10
17
  Heading2,
18
+ Heading3,
19
+ Heading4,
20
+ Heading5,
11
21
  List,
12
22
  ListOrdered,
23
+ Quote,
24
+ Indent,
25
+ Outdent,
26
+ Link as LinkIcon,
27
+ Unlink,
28
+ Table as TableIcon,
29
+ Rows3,
30
+ TableRowsSplit,
31
+ Columns3,
32
+ TableColumnsSplit,
33
+ TableProperties,
34
+ Trash2,
13
35
  Undo2,
14
36
  Redo2,
15
37
  Smile,
@@ -19,7 +41,10 @@ import {
19
41
  // Editor de texto enriquecido (WYSIWYG) basado en TipTap (DC-09).
20
42
  // v-model = HTML. Agnóstico de i18n. Si se pasan `icons`, muestra un selector
21
43
  // para insertar iconos del juego (como en CDL): se insertan como <img.rt-icon>.
22
- // Incluye un toggle para editar en modo visual o directamente el HTML.
44
+ // Incluye un toggle para editar en modo visual o directamente el HTML (para
45
+ // pegar HTML ya hecho, p. ej. una tabla) y soporte de tablas completo
46
+ // (insertar, filas/columnas, cabecera, borrar). El HTML que sale de aquí NO
47
+ // se sanea en cliente: pasa por `HtmlSanitizer` (core) al guardar.
23
48
  export interface RichIcon {
24
49
  name: string
25
50
  url: string
@@ -30,9 +55,27 @@ export interface RichTextLabels {
30
55
  bold: string
31
56
  italic: string
32
57
  strike: string
33
- heading: string
58
+ underline: string
59
+ heading2: string
60
+ heading3: string
61
+ heading4: string
62
+ heading5: string
34
63
  bulletList: string
35
64
  orderedList: string
65
+ blockquote: string
66
+ indent: string
67
+ outdent: string
68
+ link: string
69
+ unlink: string
70
+ linkUrl: string
71
+ linkApply: string
72
+ insertTable: string
73
+ addRow: string
74
+ removeRow: string
75
+ addColumn: string
76
+ removeColumn: string
77
+ toggleHeaderRow: string
78
+ deleteTable: string
36
79
  undo: string
37
80
  redo: string
38
81
  insertIcon: string
@@ -43,9 +86,27 @@ const defaultLabels: RichTextLabels = {
43
86
  bold: 'Negrita',
44
87
  italic: 'Cursiva',
45
88
  strike: 'Tachado',
46
- heading: 'Título',
89
+ underline: 'Subrayado',
90
+ heading2: 'Título 2',
91
+ heading3: 'Título 3',
92
+ heading4: 'Título 4',
93
+ heading5: 'Título 5',
47
94
  bulletList: 'Lista',
48
95
  orderedList: 'Lista numerada',
96
+ blockquote: 'Cita',
97
+ indent: 'Sangrar',
98
+ outdent: 'Quitar sangría',
99
+ link: 'Enlace',
100
+ unlink: 'Quitar enlace',
101
+ linkUrl: 'URL del enlace',
102
+ linkApply: 'Aplicar',
103
+ insertTable: 'Insertar tabla',
104
+ addRow: 'Añadir fila',
105
+ removeRow: 'Quitar fila',
106
+ addColumn: 'Añadir columna',
107
+ removeColumn: 'Quitar columna',
108
+ toggleHeaderRow: 'Alternar cabecera',
109
+ deleteTable: 'Borrar tabla',
49
110
  undo: 'Deshacer',
50
111
  redo: 'Rehacer',
51
112
  insertIcon: 'Insertar icono',
@@ -74,7 +135,23 @@ const editor = useEditor({
74
135
  content: props.modelValue,
75
136
  editable: !props.disabled,
76
137
  extensions: [
77
- StarterKit,
138
+ StarterKit.configure({ heading: { levels: [2, 3, 4, 5] } }),
139
+ UnderlineExtension,
140
+ // Enlaces: no se navega al hacer click dentro del editor (openOnClick),
141
+ // y TODOS llevan target="_blank" + rel="noopener noreferrer" por
142
+ // defecto (también los mailto: — ahí son inocuos, pero así no hace
143
+ // falta distinguir el esquema al aplicarlos).
144
+ LinkExtension.configure({
145
+ openOnClick: false,
146
+ autolink: false,
147
+ HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
148
+ }),
149
+ // Tablas: sin resize de columnas (el asa de arrastre metía ruido visual
150
+ // y no hace falta para el caso de uso del editor).
151
+ TableExtension.configure({ resizable: false }),
152
+ TableRow,
153
+ TableHeader,
154
+ TableCell,
78
155
  // Los iconos son la única imagen que se inserta: en línea y con clase fija.
79
156
  Image.configure({ inline: true, HTMLAttributes: { class: 'rt-icon' } }),
80
157
  ],
@@ -98,6 +175,34 @@ watch(
98
175
  (d) => editor.value?.setEditable(!d),
99
176
  )
100
177
 
178
+ /** Alterna un nivel de título (o vuelve a párrafo si ya lo era). */
179
+ function toggleHeading(level: 2 | 3 | 4 | 5) {
180
+ editor.value?.chain().focus().toggleHeading({ level }).run()
181
+ }
182
+
183
+ /** Sangrar: solo tiene sentido dentro de un item de lista (anida la sublista). */
184
+ function indent() {
185
+ editor.value?.chain().focus().sinkListItem('listItem').run()
186
+ }
187
+ function canIndent(): boolean {
188
+ return !!editor.value?.can().sinkListItem('listItem')
189
+ }
190
+
191
+ /** Quitar sangría: en una lista, la saca un nivel; si no, saca de la cita. */
192
+ function outdent() {
193
+ const e = editor.value
194
+ if (!e) return
195
+ if (e.isActive('listItem')) e.chain().focus().liftListItem('listItem').run()
196
+ else if (e.isActive('blockquote')) e.chain().focus().lift('blockquote').run()
197
+ }
198
+ function canOutdent(): boolean {
199
+ const e = editor.value
200
+ if (!e) return false
201
+ if (e.isActive('listItem')) return e.can().liftListItem('listItem')
202
+ if (e.isActive('blockquote')) return e.can().lift('blockquote')
203
+ return false
204
+ }
205
+
101
206
  const tools = computed(() => [
102
207
  {
103
208
  key: 'bold',
@@ -120,13 +225,41 @@ const tools = computed(() => [
120
225
  run: () => editor.value?.chain().focus().toggleStrike().run(),
121
226
  active: () => editor.value?.isActive('strike'),
122
227
  },
228
+ {
229
+ key: 'underline',
230
+ icon: UnderlineIcon,
231
+ title: t.value.underline,
232
+ run: () => editor.value?.chain().focus().toggleUnderline().run(),
233
+ active: () => editor.value?.isActive('underline'),
234
+ },
123
235
  {
124
236
  key: 'h2',
125
237
  icon: Heading2,
126
- title: t.value.heading,
127
- run: () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run(),
238
+ title: t.value.heading2,
239
+ run: () => toggleHeading(2),
128
240
  active: () => editor.value?.isActive('heading', { level: 2 }),
129
241
  },
242
+ {
243
+ key: 'h3',
244
+ icon: Heading3,
245
+ title: t.value.heading3,
246
+ run: () => toggleHeading(3),
247
+ active: () => editor.value?.isActive('heading', { level: 3 }),
248
+ },
249
+ {
250
+ key: 'h4',
251
+ icon: Heading4,
252
+ title: t.value.heading4,
253
+ run: () => toggleHeading(4),
254
+ active: () => editor.value?.isActive('heading', { level: 4 }),
255
+ },
256
+ {
257
+ key: 'h5',
258
+ icon: Heading5,
259
+ title: t.value.heading5,
260
+ run: () => toggleHeading(5),
261
+ active: () => editor.value?.isActive('heading', { level: 5 }),
262
+ },
130
263
  {
131
264
  key: 'ul',
132
265
  icon: List,
@@ -141,8 +274,61 @@ const tools = computed(() => [
141
274
  run: () => editor.value?.chain().focus().toggleOrderedList().run(),
142
275
  active: () => editor.value?.isActive('orderedList'),
143
276
  },
277
+ {
278
+ key: 'blockquote',
279
+ icon: Quote,
280
+ title: t.value.blockquote,
281
+ run: () => editor.value?.chain().focus().toggleBlockquote().run(),
282
+ active: () => editor.value?.isActive('blockquote'),
283
+ },
284
+ ])
285
+
286
+ // Controles de tabla contextuales: solo con el cursor DENTRO de una tabla
287
+ // (salvo "insertar", que se deshabilita si ya lo está — no hay tablas
288
+ // anidadas).
289
+ const tableTools = computed(() => [
290
+ {
291
+ key: 'addRow',
292
+ icon: Rows3,
293
+ title: t.value.addRow,
294
+ run: () => editor.value?.chain().focus().addRowAfter().run(),
295
+ },
296
+ {
297
+ key: 'removeRow',
298
+ icon: TableRowsSplit,
299
+ title: t.value.removeRow,
300
+ run: () => editor.value?.chain().focus().deleteRow().run(),
301
+ },
302
+ {
303
+ key: 'addColumn',
304
+ icon: Columns3,
305
+ title: t.value.addColumn,
306
+ run: () => editor.value?.chain().focus().addColumnAfter().run(),
307
+ },
308
+ {
309
+ key: 'removeColumn',
310
+ icon: TableColumnsSplit,
311
+ title: t.value.removeColumn,
312
+ run: () => editor.value?.chain().focus().deleteColumn().run(),
313
+ },
314
+ {
315
+ key: 'toggleHeaderRow',
316
+ icon: TableProperties,
317
+ title: t.value.toggleHeaderRow,
318
+ run: () => editor.value?.chain().focus().toggleHeaderRow().run(),
319
+ },
320
+ {
321
+ key: 'deleteTable',
322
+ icon: Trash2,
323
+ title: t.value.deleteTable,
324
+ run: () => editor.value?.chain().focus().deleteTable().run(),
325
+ },
144
326
  ])
145
327
 
328
+ function insertTable() {
329
+ editor.value?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
330
+ }
331
+
146
332
  // --- Selector de iconos ---
147
333
  const pickerOpen = ref(false)
148
334
  const pickerRef = ref<HTMLElement>()
@@ -151,8 +337,40 @@ function insertIcon(icon: RichIcon) {
151
337
  editor.value?.chain().focus().setImage({ src: icon.url, alt: icon.name, title: icon.name }).run()
152
338
  pickerOpen.value = false
153
339
  }
340
+
341
+ // --- Enlaces: mini-popover con la URL (mismo patrón que el selector de
342
+ // iconos: botón + panel flotante, cierra con Escape o click fuera) ---
343
+ const linkPopoverOpen = ref(false)
344
+ const linkPickerRef = ref<HTMLElement>()
345
+ const linkInputRef = ref<HTMLInputElement>()
346
+ const linkUrlInput = ref('')
347
+
348
+ function toggleLinkPopover() {
349
+ if (linkPopoverOpen.value) {
350
+ linkPopoverOpen.value = false
351
+ return
352
+ }
353
+ linkUrlInput.value = (editor.value?.getAttributes('link').href as string) ?? ''
354
+ pickerOpen.value = false
355
+ linkPopoverOpen.value = true
356
+ void nextTick(() => linkInputRef.value?.focus())
357
+ }
358
+
359
+ function applyLink() {
360
+ const url = linkUrlInput.value.trim()
361
+ if (url) editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
362
+ else editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
363
+ linkPopoverOpen.value = false
364
+ }
365
+
366
+ function removeLink() {
367
+ editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
368
+ }
369
+
154
370
  function onClickOutside(e: MouseEvent) {
155
- if (pickerRef.value && !pickerRef.value.contains(e.target as Node)) pickerOpen.value = false
371
+ const target = e.target as Node
372
+ if (pickerRef.value && !pickerRef.value.contains(target)) pickerOpen.value = false
373
+ if (linkPickerRef.value && !linkPickerRef.value.contains(target)) linkPopoverOpen.value = false
156
374
  }
157
375
  onMounted(() => document.addEventListener('click', onClickOutside))
158
376
  onBeforeUnmount(() => {
@@ -168,11 +386,13 @@ function toggleSource() {
168
386
  // Entrar a modo HTML: cargar el HTML actual.
169
387
  sourceHtml.value = editor.value?.isEmpty ? '' : (editor.value?.getHTML() ?? '')
170
388
  } else {
171
- // Volver a visual: aplicar el HTML editado al editor.
389
+ // Volver a visual: aplicar el HTML editado al editor (pasará por el
390
+ // saneador del servidor al guardar; aquí no hace falta sanear).
172
391
  editor.value?.commands.setContent(sourceHtml.value || '', false)
173
392
  }
174
393
  source.value = !source.value
175
394
  pickerOpen.value = false
395
+ linkPopoverOpen.value = false
176
396
  }
177
397
 
178
398
  function onSourceInput(value: string) {
@@ -197,6 +417,93 @@ function onSourceInput(value: string) {
197
417
  >
198
418
  <component :is="tool.icon" :size="16" />
199
419
  </button>
420
+
421
+ <button
422
+ type="button"
423
+ class="rich-text__tool"
424
+ :title="t.indent"
425
+ :disabled="disabled || !canIndent()"
426
+ @click="indent"
427
+ >
428
+ <Indent :size="16" />
429
+ </button>
430
+ <button
431
+ type="button"
432
+ class="rich-text__tool"
433
+ :title="t.outdent"
434
+ :disabled="disabled || !canOutdent()"
435
+ @click="outdent"
436
+ >
437
+ <Outdent :size="16" />
438
+ </button>
439
+
440
+ <span class="rich-text__sep" />
441
+
442
+ <!-- Enlace: botón + mini-popover con la URL -->
443
+ <div ref="linkPickerRef" class="rich-text__picker">
444
+ <button
445
+ type="button"
446
+ class="rich-text__tool"
447
+ :class="{ 'rich-text__tool--active': editor?.isActive('link') || linkPopoverOpen }"
448
+ :title="t.link"
449
+ :disabled="disabled"
450
+ @click="toggleLinkPopover"
451
+ >
452
+ <LinkIcon :size="16" />
453
+ </button>
454
+ <div v-if="linkPopoverOpen" class="rich-text__link-popover">
455
+ <input
456
+ ref="linkInputRef"
457
+ v-model="linkUrlInput"
458
+ type="text"
459
+ class="rich-text__link-input"
460
+ :placeholder="t.linkUrl"
461
+ @keydown.enter.prevent="applyLink"
462
+ @keydown.escape.prevent="linkPopoverOpen = false"
463
+ />
464
+ <button type="button" class="rich-text__tool" :title="t.linkApply" @click="applyLink">
465
+ <LinkIcon :size="14" />
466
+ </button>
467
+ </div>
468
+ </div>
469
+ <button
470
+ v-if="editor?.isActive('link')"
471
+ type="button"
472
+ class="rich-text__tool"
473
+ :title="t.unlink"
474
+ :disabled="disabled"
475
+ @click="removeLink"
476
+ >
477
+ <Unlink :size="16" />
478
+ </button>
479
+
480
+ <span class="rich-text__sep" />
481
+
482
+ <!-- Tabla: insertar siempre visible (deshabilitado dentro de otra
483
+ tabla); el resto de controles solo con el cursor DENTRO -->
484
+ <button
485
+ type="button"
486
+ class="rich-text__tool"
487
+ :title="t.insertTable"
488
+ :disabled="disabled || editor?.isActive('table')"
489
+ @click="insertTable"
490
+ >
491
+ <TableIcon :size="16" />
492
+ </button>
493
+ <template v-if="editor?.isActive('table')">
494
+ <button
495
+ v-for="tool in tableTools"
496
+ :key="tool.key"
497
+ type="button"
498
+ class="rich-text__tool"
499
+ :title="tool.title"
500
+ :disabled="disabled"
501
+ @click="tool.run()"
502
+ >
503
+ <component :is="tool.icon" :size="16" />
504
+ </button>
505
+ </template>
506
+
200
507
  <span class="rich-text__sep" />
201
508
  <button
202
509
  type="button"