@edc-motor/ui 0.4.27 → 0.4.29
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.
|
|
3
|
+
"version": "0.4.29",
|
|
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,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
|
}
|
|
@@ -49,6 +49,15 @@
|
|
|
49
49
|
li + li { margin-block-start: $space-1; }
|
|
50
50
|
}
|
|
51
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
|
+
|
|
52
61
|
// Iconos del juego insertados en línea por el editor (<img class="rt-icon">).
|
|
53
62
|
img.rt-icon {
|
|
54
63
|
display: inline-block;
|
|
@@ -57,4 +66,29 @@
|
|
|
57
66
|
vertical-align: -0.24em;
|
|
58
67
|
margin: 0 1px;
|
|
59
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
|
+
}
|
|
60
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 {
|
|
@@ -1,15 +1,38 @@
|
|
|
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 { useDropdownPanel } from '../composables/useDropdownPanel'
|
|
7
|
+
import UnderlineExtension from '@tiptap/extension-underline'
|
|
8
|
+
import LinkExtension from '@tiptap/extension-link'
|
|
9
|
+
import TableExtension from '@tiptap/extension-table'
|
|
10
|
+
import TableRow from '@tiptap/extension-table-row'
|
|
11
|
+
import TableHeader from '@tiptap/extension-table-header'
|
|
12
|
+
import TableCell from '@tiptap/extension-table-cell'
|
|
6
13
|
import {
|
|
7
14
|
Bold,
|
|
8
15
|
Italic,
|
|
9
16
|
Strikethrough,
|
|
17
|
+
Underline as UnderlineIcon,
|
|
10
18
|
Heading2,
|
|
19
|
+
Heading3,
|
|
20
|
+
Heading4,
|
|
21
|
+
Heading5,
|
|
11
22
|
List,
|
|
12
23
|
ListOrdered,
|
|
24
|
+
Quote,
|
|
25
|
+
Indent,
|
|
26
|
+
Outdent,
|
|
27
|
+
Link as LinkIcon,
|
|
28
|
+
Unlink,
|
|
29
|
+
Table as TableIcon,
|
|
30
|
+
Rows3,
|
|
31
|
+
TableRowsSplit,
|
|
32
|
+
Columns3,
|
|
33
|
+
TableColumnsSplit,
|
|
34
|
+
TableProperties,
|
|
35
|
+
Trash2,
|
|
13
36
|
Undo2,
|
|
14
37
|
Redo2,
|
|
15
38
|
Smile,
|
|
@@ -19,7 +42,10 @@ import {
|
|
|
19
42
|
// Editor de texto enriquecido (WYSIWYG) basado en TipTap (DC-09).
|
|
20
43
|
// v-model = HTML. Agnóstico de i18n. Si se pasan `icons`, muestra un selector
|
|
21
44
|
// 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
|
|
45
|
+
// Incluye un toggle para editar en modo visual o directamente el HTML (para
|
|
46
|
+
// pegar HTML ya hecho, p. ej. una tabla) y soporte de tablas completo
|
|
47
|
+
// (insertar, filas/columnas, cabecera, borrar). El HTML que sale de aquí NO
|
|
48
|
+
// se sanea en cliente: pasa por `HtmlSanitizer` (core) al guardar.
|
|
23
49
|
export interface RichIcon {
|
|
24
50
|
name: string
|
|
25
51
|
url: string
|
|
@@ -30,9 +56,27 @@ export interface RichTextLabels {
|
|
|
30
56
|
bold: string
|
|
31
57
|
italic: string
|
|
32
58
|
strike: string
|
|
33
|
-
|
|
59
|
+
underline: string
|
|
60
|
+
heading2: string
|
|
61
|
+
heading3: string
|
|
62
|
+
heading4: string
|
|
63
|
+
heading5: string
|
|
34
64
|
bulletList: string
|
|
35
65
|
orderedList: string
|
|
66
|
+
blockquote: string
|
|
67
|
+
indent: string
|
|
68
|
+
outdent: string
|
|
69
|
+
link: string
|
|
70
|
+
unlink: string
|
|
71
|
+
linkUrl: string
|
|
72
|
+
linkApply: string
|
|
73
|
+
insertTable: string
|
|
74
|
+
addRow: string
|
|
75
|
+
removeRow: string
|
|
76
|
+
addColumn: string
|
|
77
|
+
removeColumn: string
|
|
78
|
+
toggleHeaderRow: string
|
|
79
|
+
deleteTable: string
|
|
36
80
|
undo: string
|
|
37
81
|
redo: string
|
|
38
82
|
insertIcon: string
|
|
@@ -43,9 +87,27 @@ const defaultLabels: RichTextLabels = {
|
|
|
43
87
|
bold: 'Negrita',
|
|
44
88
|
italic: 'Cursiva',
|
|
45
89
|
strike: 'Tachado',
|
|
46
|
-
|
|
90
|
+
underline: 'Subrayado',
|
|
91
|
+
heading2: 'Título 2',
|
|
92
|
+
heading3: 'Título 3',
|
|
93
|
+
heading4: 'Título 4',
|
|
94
|
+
heading5: 'Título 5',
|
|
47
95
|
bulletList: 'Lista',
|
|
48
96
|
orderedList: 'Lista numerada',
|
|
97
|
+
blockquote: 'Cita',
|
|
98
|
+
indent: 'Sangrar',
|
|
99
|
+
outdent: 'Quitar sangría',
|
|
100
|
+
link: 'Enlace',
|
|
101
|
+
unlink: 'Quitar enlace',
|
|
102
|
+
linkUrl: 'URL del enlace',
|
|
103
|
+
linkApply: 'Aplicar',
|
|
104
|
+
insertTable: 'Insertar tabla',
|
|
105
|
+
addRow: 'Añadir fila',
|
|
106
|
+
removeRow: 'Quitar fila',
|
|
107
|
+
addColumn: 'Añadir columna',
|
|
108
|
+
removeColumn: 'Quitar columna',
|
|
109
|
+
toggleHeaderRow: 'Alternar cabecera',
|
|
110
|
+
deleteTable: 'Borrar tabla',
|
|
49
111
|
undo: 'Deshacer',
|
|
50
112
|
redo: 'Rehacer',
|
|
51
113
|
insertIcon: 'Insertar icono',
|
|
@@ -74,7 +136,23 @@ const editor = useEditor({
|
|
|
74
136
|
content: props.modelValue,
|
|
75
137
|
editable: !props.disabled,
|
|
76
138
|
extensions: [
|
|
77
|
-
StarterKit,
|
|
139
|
+
StarterKit.configure({ heading: { levels: [2, 3, 4, 5] } }),
|
|
140
|
+
UnderlineExtension,
|
|
141
|
+
// Enlaces: no se navega al hacer click dentro del editor (openOnClick),
|
|
142
|
+
// y TODOS llevan target="_blank" + rel="noopener noreferrer" por
|
|
143
|
+
// defecto (también los mailto: — ahí son inocuos, pero así no hace
|
|
144
|
+
// falta distinguir el esquema al aplicarlos).
|
|
145
|
+
LinkExtension.configure({
|
|
146
|
+
openOnClick: false,
|
|
147
|
+
autolink: false,
|
|
148
|
+
HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
|
|
149
|
+
}),
|
|
150
|
+
// Tablas: sin resize de columnas (el asa de arrastre metía ruido visual
|
|
151
|
+
// y no hace falta para el caso de uso del editor).
|
|
152
|
+
TableExtension.configure({ resizable: false }),
|
|
153
|
+
TableRow,
|
|
154
|
+
TableHeader,
|
|
155
|
+
TableCell,
|
|
78
156
|
// Los iconos son la única imagen que se inserta: en línea y con clase fija.
|
|
79
157
|
Image.configure({ inline: true, HTMLAttributes: { class: 'rt-icon' } }),
|
|
80
158
|
],
|
|
@@ -98,6 +176,34 @@ watch(
|
|
|
98
176
|
(d) => editor.value?.setEditable(!d),
|
|
99
177
|
)
|
|
100
178
|
|
|
179
|
+
/** Alterna un nivel de título (o vuelve a párrafo si ya lo era). */
|
|
180
|
+
function toggleHeading(level: 2 | 3 | 4 | 5) {
|
|
181
|
+
editor.value?.chain().focus().toggleHeading({ level }).run()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Sangrar: solo tiene sentido dentro de un item de lista (anida la sublista). */
|
|
185
|
+
function indent() {
|
|
186
|
+
editor.value?.chain().focus().sinkListItem('listItem').run()
|
|
187
|
+
}
|
|
188
|
+
function canIndent(): boolean {
|
|
189
|
+
return !!editor.value?.can().sinkListItem('listItem')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Quitar sangría: en una lista, la saca un nivel; si no, saca de la cita. */
|
|
193
|
+
function outdent() {
|
|
194
|
+
const e = editor.value
|
|
195
|
+
if (!e) return
|
|
196
|
+
if (e.isActive('listItem')) e.chain().focus().liftListItem('listItem').run()
|
|
197
|
+
else if (e.isActive('blockquote')) e.chain().focus().lift('blockquote').run()
|
|
198
|
+
}
|
|
199
|
+
function canOutdent(): boolean {
|
|
200
|
+
const e = editor.value
|
|
201
|
+
if (!e) return false
|
|
202
|
+
if (e.isActive('listItem')) return e.can().liftListItem('listItem')
|
|
203
|
+
if (e.isActive('blockquote')) return e.can().lift('blockquote')
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
|
|
101
207
|
const tools = computed(() => [
|
|
102
208
|
{
|
|
103
209
|
key: 'bold',
|
|
@@ -120,13 +226,41 @@ const tools = computed(() => [
|
|
|
120
226
|
run: () => editor.value?.chain().focus().toggleStrike().run(),
|
|
121
227
|
active: () => editor.value?.isActive('strike'),
|
|
122
228
|
},
|
|
229
|
+
{
|
|
230
|
+
key: 'underline',
|
|
231
|
+
icon: UnderlineIcon,
|
|
232
|
+
title: t.value.underline,
|
|
233
|
+
run: () => editor.value?.chain().focus().toggleUnderline().run(),
|
|
234
|
+
active: () => editor.value?.isActive('underline'),
|
|
235
|
+
},
|
|
123
236
|
{
|
|
124
237
|
key: 'h2',
|
|
125
238
|
icon: Heading2,
|
|
126
|
-
title: t.value.
|
|
127
|
-
run: () =>
|
|
239
|
+
title: t.value.heading2,
|
|
240
|
+
run: () => toggleHeading(2),
|
|
128
241
|
active: () => editor.value?.isActive('heading', { level: 2 }),
|
|
129
242
|
},
|
|
243
|
+
{
|
|
244
|
+
key: 'h3',
|
|
245
|
+
icon: Heading3,
|
|
246
|
+
title: t.value.heading3,
|
|
247
|
+
run: () => toggleHeading(3),
|
|
248
|
+
active: () => editor.value?.isActive('heading', { level: 3 }),
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
key: 'h4',
|
|
252
|
+
icon: Heading4,
|
|
253
|
+
title: t.value.heading4,
|
|
254
|
+
run: () => toggleHeading(4),
|
|
255
|
+
active: () => editor.value?.isActive('heading', { level: 4 }),
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
key: 'h5',
|
|
259
|
+
icon: Heading5,
|
|
260
|
+
title: t.value.heading5,
|
|
261
|
+
run: () => toggleHeading(5),
|
|
262
|
+
active: () => editor.value?.isActive('heading', { level: 5 }),
|
|
263
|
+
},
|
|
130
264
|
{
|
|
131
265
|
key: 'ul',
|
|
132
266
|
icon: List,
|
|
@@ -141,18 +275,110 @@ const tools = computed(() => [
|
|
|
141
275
|
run: () => editor.value?.chain().focus().toggleOrderedList().run(),
|
|
142
276
|
active: () => editor.value?.isActive('orderedList'),
|
|
143
277
|
},
|
|
278
|
+
{
|
|
279
|
+
key: 'blockquote',
|
|
280
|
+
icon: Quote,
|
|
281
|
+
title: t.value.blockquote,
|
|
282
|
+
run: () => editor.value?.chain().focus().toggleBlockquote().run(),
|
|
283
|
+
active: () => editor.value?.isActive('blockquote'),
|
|
284
|
+
},
|
|
285
|
+
])
|
|
286
|
+
|
|
287
|
+
// Controles de tabla contextuales: solo con el cursor DENTRO de una tabla
|
|
288
|
+
// (salvo "insertar", que se deshabilita si ya lo está — no hay tablas
|
|
289
|
+
// anidadas).
|
|
290
|
+
const tableTools = computed(() => [
|
|
291
|
+
{
|
|
292
|
+
key: 'addRow',
|
|
293
|
+
icon: Rows3,
|
|
294
|
+
title: t.value.addRow,
|
|
295
|
+
run: () => editor.value?.chain().focus().addRowAfter().run(),
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
key: 'removeRow',
|
|
299
|
+
icon: TableRowsSplit,
|
|
300
|
+
title: t.value.removeRow,
|
|
301
|
+
run: () => editor.value?.chain().focus().deleteRow().run(),
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
key: 'addColumn',
|
|
305
|
+
icon: Columns3,
|
|
306
|
+
title: t.value.addColumn,
|
|
307
|
+
run: () => editor.value?.chain().focus().addColumnAfter().run(),
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
key: 'removeColumn',
|
|
311
|
+
icon: TableColumnsSplit,
|
|
312
|
+
title: t.value.removeColumn,
|
|
313
|
+
run: () => editor.value?.chain().focus().deleteColumn().run(),
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
key: 'toggleHeaderRow',
|
|
317
|
+
icon: TableProperties,
|
|
318
|
+
title: t.value.toggleHeaderRow,
|
|
319
|
+
run: () => editor.value?.chain().focus().toggleHeaderRow().run(),
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
key: 'deleteTable',
|
|
323
|
+
icon: Trash2,
|
|
324
|
+
title: t.value.deleteTable,
|
|
325
|
+
run: () => editor.value?.chain().focus().deleteTable().run(),
|
|
326
|
+
},
|
|
144
327
|
])
|
|
145
328
|
|
|
329
|
+
function insertTable() {
|
|
330
|
+
editor.value?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
|
|
331
|
+
}
|
|
332
|
+
|
|
146
333
|
// --- Selector de iconos ---
|
|
147
334
|
const pickerOpen = ref(false)
|
|
148
|
-
const pickerRef = ref<HTMLElement>()
|
|
335
|
+
const pickerRef = ref<HTMLElement | null>(null)
|
|
336
|
+
const iconsPanelRef = ref<HTMLElement | null>(null)
|
|
337
|
+
// Top layer (popover): dentro de un modal, el overflow del cuerpo recortaba
|
|
338
|
+
// el panel y no se podían elegir todos los iconos. Ancho propio, sin igualar
|
|
339
|
+
// al trigger.
|
|
340
|
+
useDropdownPanel(pickerRef, iconsPanelRef, pickerOpen, { matchWidth: false })
|
|
149
341
|
|
|
150
342
|
function insertIcon(icon: RichIcon) {
|
|
151
343
|
editor.value?.chain().focus().setImage({ src: icon.url, alt: icon.name, title: icon.name }).run()
|
|
152
344
|
pickerOpen.value = false
|
|
153
345
|
}
|
|
346
|
+
|
|
347
|
+
// --- Enlaces: mini-popover con la URL (mismo patrón que el selector de
|
|
348
|
+
// iconos: botón + panel flotante, cierra con Escape o click fuera) ---
|
|
349
|
+
const linkPopoverOpen = ref(false)
|
|
350
|
+
const linkPickerRef = ref<HTMLElement | null>(null)
|
|
351
|
+
const linkPanelRef = ref<HTMLElement | null>(null)
|
|
352
|
+
useDropdownPanel(linkPickerRef, linkPanelRef, linkPopoverOpen, { matchWidth: false })
|
|
353
|
+
const linkInputRef = ref<HTMLInputElement>()
|
|
354
|
+
const linkUrlInput = ref('')
|
|
355
|
+
|
|
356
|
+
function toggleLinkPopover() {
|
|
357
|
+
if (linkPopoverOpen.value) {
|
|
358
|
+
linkPopoverOpen.value = false
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
linkUrlInput.value = (editor.value?.getAttributes('link').href as string) ?? ''
|
|
362
|
+
pickerOpen.value = false
|
|
363
|
+
linkPopoverOpen.value = true
|
|
364
|
+
void nextTick(() => linkInputRef.value?.focus())
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function applyLink() {
|
|
368
|
+
const url = linkUrlInput.value.trim()
|
|
369
|
+
if (url) editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
|
370
|
+
else editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
371
|
+
linkPopoverOpen.value = false
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function removeLink() {
|
|
375
|
+
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
376
|
+
}
|
|
377
|
+
|
|
154
378
|
function onClickOutside(e: MouseEvent) {
|
|
155
|
-
|
|
379
|
+
const target = e.target as Node
|
|
380
|
+
if (pickerRef.value && !pickerRef.value.contains(target)) pickerOpen.value = false
|
|
381
|
+
if (linkPickerRef.value && !linkPickerRef.value.contains(target)) linkPopoverOpen.value = false
|
|
156
382
|
}
|
|
157
383
|
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
158
384
|
onBeforeUnmount(() => {
|
|
@@ -168,11 +394,13 @@ function toggleSource() {
|
|
|
168
394
|
// Entrar a modo HTML: cargar el HTML actual.
|
|
169
395
|
sourceHtml.value = editor.value?.isEmpty ? '' : (editor.value?.getHTML() ?? '')
|
|
170
396
|
} else {
|
|
171
|
-
// Volver a visual: aplicar el HTML editado al editor
|
|
397
|
+
// Volver a visual: aplicar el HTML editado al editor (pasará por el
|
|
398
|
+
// saneador del servidor al guardar; aquí no hace falta sanear).
|
|
172
399
|
editor.value?.commands.setContent(sourceHtml.value || '', false)
|
|
173
400
|
}
|
|
174
401
|
source.value = !source.value
|
|
175
402
|
pickerOpen.value = false
|
|
403
|
+
linkPopoverOpen.value = false
|
|
176
404
|
}
|
|
177
405
|
|
|
178
406
|
function onSourceInput(value: string) {
|
|
@@ -197,6 +425,98 @@ function onSourceInput(value: string) {
|
|
|
197
425
|
>
|
|
198
426
|
<component :is="tool.icon" :size="16" />
|
|
199
427
|
</button>
|
|
428
|
+
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
class="rich-text__tool"
|
|
432
|
+
:title="t.indent"
|
|
433
|
+
:disabled="disabled || !canIndent()"
|
|
434
|
+
@click="indent"
|
|
435
|
+
>
|
|
436
|
+
<Indent :size="16" />
|
|
437
|
+
</button>
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
class="rich-text__tool"
|
|
441
|
+
:title="t.outdent"
|
|
442
|
+
:disabled="disabled || !canOutdent()"
|
|
443
|
+
@click="outdent"
|
|
444
|
+
>
|
|
445
|
+
<Outdent :size="16" />
|
|
446
|
+
</button>
|
|
447
|
+
|
|
448
|
+
<span class="rich-text__sep" />
|
|
449
|
+
|
|
450
|
+
<!-- Enlace: botón + mini-popover con la URL -->
|
|
451
|
+
<div ref="linkPickerRef" class="rich-text__picker">
|
|
452
|
+
<button
|
|
453
|
+
type="button"
|
|
454
|
+
class="rich-text__tool"
|
|
455
|
+
:class="{ 'rich-text__tool--active': editor?.isActive('link') || linkPopoverOpen }"
|
|
456
|
+
:title="t.link"
|
|
457
|
+
:disabled="disabled"
|
|
458
|
+
@click="toggleLinkPopover"
|
|
459
|
+
>
|
|
460
|
+
<LinkIcon :size="16" />
|
|
461
|
+
</button>
|
|
462
|
+
<div
|
|
463
|
+
v-if="linkPopoverOpen"
|
|
464
|
+
ref="linkPanelRef"
|
|
465
|
+
class="rich-text__link-popover"
|
|
466
|
+
popover="manual"
|
|
467
|
+
>
|
|
468
|
+
<input
|
|
469
|
+
ref="linkInputRef"
|
|
470
|
+
v-model="linkUrlInput"
|
|
471
|
+
type="text"
|
|
472
|
+
class="rich-text__link-input"
|
|
473
|
+
:placeholder="t.linkUrl"
|
|
474
|
+
@keydown.enter.prevent="applyLink"
|
|
475
|
+
@keydown.escape.prevent="linkPopoverOpen = false"
|
|
476
|
+
/>
|
|
477
|
+
<button type="button" class="rich-text__tool" :title="t.linkApply" @click="applyLink">
|
|
478
|
+
<LinkIcon :size="14" />
|
|
479
|
+
</button>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
<button
|
|
483
|
+
v-if="editor?.isActive('link')"
|
|
484
|
+
type="button"
|
|
485
|
+
class="rich-text__tool"
|
|
486
|
+
:title="t.unlink"
|
|
487
|
+
:disabled="disabled"
|
|
488
|
+
@click="removeLink"
|
|
489
|
+
>
|
|
490
|
+
<Unlink :size="16" />
|
|
491
|
+
</button>
|
|
492
|
+
|
|
493
|
+
<span class="rich-text__sep" />
|
|
494
|
+
|
|
495
|
+
<!-- Tabla: insertar siempre visible (deshabilitado dentro de otra
|
|
496
|
+
tabla); el resto de controles solo con el cursor DENTRO -->
|
|
497
|
+
<button
|
|
498
|
+
type="button"
|
|
499
|
+
class="rich-text__tool"
|
|
500
|
+
:title="t.insertTable"
|
|
501
|
+
:disabled="disabled || editor?.isActive('table')"
|
|
502
|
+
@click="insertTable"
|
|
503
|
+
>
|
|
504
|
+
<TableIcon :size="16" />
|
|
505
|
+
</button>
|
|
506
|
+
<template v-if="editor?.isActive('table')">
|
|
507
|
+
<button
|
|
508
|
+
v-for="tool in tableTools"
|
|
509
|
+
:key="tool.key"
|
|
510
|
+
type="button"
|
|
511
|
+
class="rich-text__tool"
|
|
512
|
+
:title="tool.title"
|
|
513
|
+
:disabled="disabled"
|
|
514
|
+
@click="tool.run()"
|
|
515
|
+
>
|
|
516
|
+
<component :is="tool.icon" :size="16" />
|
|
517
|
+
</button>
|
|
518
|
+
</template>
|
|
519
|
+
|
|
200
520
|
<span class="rich-text__sep" />
|
|
201
521
|
<button
|
|
202
522
|
type="button"
|
|
@@ -231,7 +551,7 @@ function onSourceInput(value: string) {
|
|
|
231
551
|
>
|
|
232
552
|
<Smile :size="16" />
|
|
233
553
|
</button>
|
|
234
|
-
<div v-if="pickerOpen" class="rich-text__icons">
|
|
554
|
+
<div v-if="pickerOpen" ref="iconsPanelRef" class="rich-text__icons" popover="manual">
|
|
235
555
|
<button
|
|
236
556
|
v-for="icon in icons"
|
|
237
557
|
:key="icon.url"
|
|
@@ -20,7 +20,14 @@ export function useDropdownPanel(
|
|
|
20
20
|
anchor: Ref<HTMLElement | null>,
|
|
21
21
|
panel: Ref<HTMLElement | null>,
|
|
22
22
|
open: Ref<boolean>,
|
|
23
|
+
options: {
|
|
24
|
+
/** Igualar el ancho del panel al del trigger (los select). Los popovers
|
|
25
|
+
* con ancho propio (selector de iconos, popover del enlace) lo apagan:
|
|
26
|
+
* conservan su ancho y solo se CLAMPEA su left para no salirse. */
|
|
27
|
+
matchWidth?: boolean
|
|
28
|
+
} = {},
|
|
23
29
|
) {
|
|
30
|
+
const { matchWidth = true } = options
|
|
24
31
|
const GAP = 4 // $space-1: aire entre trigger y panel, como el CSS absolute
|
|
25
32
|
|
|
26
33
|
/** Ancla el panel bajo el trigger (o encima, si abajo no cabe y arriba sí). */
|
|
@@ -29,8 +36,11 @@ export function useDropdownPanel(
|
|
|
29
36
|
const p = panel.value
|
|
30
37
|
if (!a || !p) return
|
|
31
38
|
const r = a.getBoundingClientRect()
|
|
32
|
-
p.style.width = `${r.width}px`
|
|
33
|
-
|
|
39
|
+
if (matchWidth) p.style.width = `${r.width}px`
|
|
40
|
+
const left = matchWidth
|
|
41
|
+
? r.left
|
|
42
|
+
: Math.max(GAP, Math.min(r.left, window.innerWidth - p.offsetWidth - GAP))
|
|
43
|
+
p.style.left = `${left}px`
|
|
34
44
|
const height = p.offsetHeight
|
|
35
45
|
const below = window.innerHeight - r.bottom - GAP
|
|
36
46
|
const openUp = height > below && r.top > window.innerHeight - r.bottom
|