@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 +12 -6
- package/scss/components/_block-related.scss +8 -4
- package/scss/components/_blocks.scss +88 -55
- package/scss/components/_form-field.scss +19 -5
- package/scss/components/_modal.scss +8 -0
- package/scss/components/_rich-content.scss +40 -5
- package/scss/components/_rich-text.scss +75 -1
- package/src/blocks/BlockCta.vue +12 -4
- package/src/blocks/BlockShell.vue +9 -2
- package/src/components/BaseModal.vue +1 -1
- package/src/components/EditModal.vue +10 -3
- package/src/components/RichTextInput.vue +316 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edc-motor/ui",
|
|
3
|
-
"version": "0.4.
|
|
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/
|
|
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 ≥
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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:
|
|
24
|
+
&--w-narrow .block__inner { max-width: 880px; }
|
|
19
25
|
&--w-full .block__inner { max-width: none; padding-inline: $space-6; }
|
|
20
26
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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:
|
|
29
|
-
&__text { color: $text-1; font-size: $fs-
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
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-
|
|
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:
|
|
126
|
-
// (la imagen absoluta no aporta altura) y el
|
|
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
|
-
|
|
179
|
-
//
|
|
180
|
-
|
|
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
|
|
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
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
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:
|
|
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 .
|
|
311
|
+
&--cta.block--image-left .block__media-layout .block__image-frame {
|
|
278
312
|
@container content (min-width: #{$bp-md}) {
|
|
279
|
-
margin
|
|
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-
|
|
347
|
-
//
|
|
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-
|
|
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-
|
|
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
|
|
400
|
-
//
|
|
401
|
-
.block__index-level-1 { font-size:
|
|
402
|
-
.block__index-level-2 { font-size:
|
|
403
|
-
.block__index-level-3 { font-size:
|
|
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-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
38
|
-
|
|
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:
|
|
41
|
-
h5 { font-size: $fs-
|
|
42
|
-
h6 { font-size: $fs-
|
|
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 {
|
package/src/blocks/BlockCta.vue
CHANGED
|
@@ -27,16 +27,24 @@ const layoutStyle = computed(() => {
|
|
|
27
27
|
]"
|
|
28
28
|
>
|
|
29
29
|
<div class="block__card">
|
|
30
|
-
<!--
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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="[
|
|
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">
|
|
@@ -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
|
-
|
|
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: '
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
127
|
-
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
|
-
|
|
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"
|