@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.27",
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/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,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
  }
@@ -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
- heading: string
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
- heading: 'Título',
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.heading,
127
- run: () => editor.value?.chain().focus().toggleHeading({ level: 2 }).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
- if (pickerRef.value && !pickerRef.value.contains(e.target as Node)) pickerOpen.value = false
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
- p.style.left = `${r.left}px`
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