@bagelink/vue 1.19.1 → 1.20.1

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.
Files changed (61) hide show
  1. package/dist/components/Dropdown.vue.d.ts +73 -48
  2. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  3. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  4. package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
  5. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts +1 -0
  6. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
  7. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
  8. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
  9. package/dist/components/form/inputs/RichText/config.d.ts.map +1 -1
  10. package/dist/components/form/inputs/RichText/index.vue.d.ts +6 -0
  11. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  12. package/dist/components/form/inputs/RichText/richTextTypes.d.ts +1 -0
  13. package/dist/components/form/inputs/RichText/richTextTypes.d.ts.map +1 -1
  14. package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
  15. package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts +7 -0
  16. package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts.map +1 -0
  17. package/dist/components/form/inputs/RichText/utils/image.d.ts +17 -0
  18. package/dist/components/form/inputs/RichText/utils/image.d.ts.map +1 -0
  19. package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts +15 -0
  20. package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts.map +1 -0
  21. package/dist/components/form/inputs/RichText/utils/selection.d.ts +1 -1
  22. package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
  23. package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
  24. package/dist/components/form/inputs/RichText/utils/video.d.ts +23 -0
  25. package/dist/components/form/inputs/RichText/utils/video.d.ts.map +1 -0
  26. package/dist/components/form/inputs/SelectBtn.vue.d.ts +28 -1
  27. package/dist/components/form/inputs/SelectBtn.vue.d.ts.map +1 -1
  28. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -0
  29. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  30. package/dist/components/form/inputs/Upload/useFileUpload.d.ts +1 -0
  31. package/dist/components/form/inputs/Upload/useFileUpload.d.ts.map +1 -1
  32. package/dist/core.css +2 -2
  33. package/dist/index.cjs +218 -130
  34. package/dist/index.css +1 -1
  35. package/dist/index.mjs +18604 -17549
  36. package/dist/style.css +2 -2
  37. package/package.json +1 -1
  38. package/src/components/Dropdown.vue +48 -7
  39. package/src/components/FilterQuery.vue +1 -1
  40. package/src/components/Icon/Icon.vue +8 -0
  41. package/src/components/dataTable/DataTable.vue +6 -3
  42. package/src/components/form/inputs/RichText/components/EditorToolbar.vue +238 -8
  43. package/src/components/form/inputs/RichText/composables/useCommands.ts +23 -5
  44. package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +28 -0
  45. package/src/components/form/inputs/RichText/config.ts +17 -14
  46. package/src/components/form/inputs/RichText/index.vue +354 -463
  47. package/src/components/form/inputs/RichText/richTextTypes.ts +1 -0
  48. package/src/components/form/inputs/RichText/utils/commands.ts +437 -24
  49. package/src/components/form/inputs/RichText/utils/contentMetrics.ts +24 -0
  50. package/src/components/form/inputs/RichText/utils/image.ts +136 -0
  51. package/src/components/form/inputs/RichText/utils/sanitizePaste.ts +110 -0
  52. package/src/components/form/inputs/RichText/utils/selection.ts +32 -0
  53. package/src/components/form/inputs/RichText/utils/table.ts +77 -8
  54. package/src/components/form/inputs/RichText/utils/video.ts +270 -0
  55. package/src/components/form/inputs/SelectBtn.vue +126 -15
  56. package/src/components/form/inputs/TelInput.vue +1 -1
  57. package/src/components/form/inputs/Upload/UploadInput.vue +3 -2
  58. package/src/components/form/inputs/Upload/useFileUpload.ts +2 -0
  59. package/src/styles/colors.css +5 -2
  60. package/src/styles/core.css +1 -0
  61. package/src/styles/rich-content.css +109 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.19.1",
4
+ "version": "1.20.1",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -1,5 +1,10 @@
1
1
  <!-- prettier-ignore-start -->
2
2
  <!-- eslint-disable -->
3
+ <script lang="ts">
4
+ // Shared across instances so opening a grouped dropdown closes its siblings.
5
+ const groupRegistry = new Map<string, Set<() => void>>()
6
+ </script>
7
+
3
8
  <script setup lang="ts">
4
9
  import type { VirtualElement } from '@floating-ui/dom'
5
10
  import type { IconType, ThemeType } from '../index'
@@ -118,9 +123,6 @@ const resolvedDelay = computed((): { show: number; hide: number } => {
118
123
  return { show: 0, hide: 0 }
119
124
  })
120
125
 
121
- // ShowGroup registry — shared across all Dropdown instances on the page
122
- const groupRegistry = new Map<string, Set<() => void>>()
123
-
124
126
  function toVirtualElement(pt: AnchorPoint): VirtualElement {
125
127
  const width = pt.width ?? 0
126
128
  const height = pt.height ?? 0
@@ -347,6 +349,12 @@ function onDocumentPointerDown(e: PointerEvent) {
347
349
  internalHide({ restoreFocus: false })
348
350
  }
349
351
 
352
+ function onEmbeddedDocumentPointerDown() {
353
+ if (props.autoHide && isOpen()) {
354
+ internalHide({ restoreFocus: false })
355
+ }
356
+ }
357
+
350
358
  // Esc key — LIFO via shared composable (nested layers close first)
351
359
  useEscapeKey(() => internalHide(), () => isOpen())
352
360
 
@@ -355,13 +363,22 @@ let showTimer: ReturnType<typeof setTimeout> | undefined
355
363
  let hideTimer: ReturnType<typeof setTimeout> | undefined
356
364
  function clearTimers() { clearTimeout(showTimer); clearTimeout(hideTimer) }
357
365
 
366
+ function hideForGroup() {
367
+ clearTimers()
368
+ internalHide({ restoreFocus: false })
369
+ }
370
+
358
371
  let lastToggleAt = 0
359
372
  function toggle(viaKeyboard = false) {
360
373
  const now = Date.now()
361
374
  if (now - lastToggleAt < 50) return
362
375
  lastToggleAt = now
363
376
  openedViaKeyboard = viaKeyboard
364
- isOpen() ? internalHide() : internalShow()
377
+ if (isOpen()) {
378
+ internalHide()
379
+ } else {
380
+ internalShow()
381
+ }
365
382
  }
366
383
 
367
384
  function onTriggerClick(e?: MouseEvent) {
@@ -425,10 +442,33 @@ function onScroll() {
425
442
  }
426
443
 
427
444
  let openListenersActive = false
445
+ const embeddedDocuments = new Set<Document>()
446
+
447
+ function addEmbeddedDocumentListeners() {
448
+ document.querySelectorAll('iframe').forEach((iframe) => {
449
+ try {
450
+ const embeddedDocument = iframe.contentDocument
451
+ if (!embeddedDocument || embeddedDocuments.has(embeddedDocument)) return
452
+ embeddedDocument.addEventListener('pointerdown', onEmbeddedDocumentPointerDown, true)
453
+ embeddedDocuments.add(embeddedDocument)
454
+ } catch {
455
+ // Cross-origin iframes are not inspectable; the browser owns their focus boundary.
456
+ }
457
+ })
458
+ }
459
+
460
+ function removeEmbeddedDocumentListeners() {
461
+ embeddedDocuments.forEach((embeddedDocument) => {
462
+ embeddedDocument.removeEventListener('pointerdown', onEmbeddedDocumentPointerDown, true)
463
+ })
464
+ embeddedDocuments.clear()
465
+ }
466
+
428
467
  function addOpenListeners() {
429
468
  if (openListenersActive) return
430
469
  document.addEventListener('pointerdown', onDocumentPointerDown, true)
431
470
  document.addEventListener('scroll', onScroll, { capture: true, passive: true })
471
+ addEmbeddedDocumentListeners()
432
472
  openListenersActive = true
433
473
  }
434
474
 
@@ -436,13 +476,14 @@ function removeOpenListeners() {
436
476
  if (!openListenersActive) return
437
477
  document.removeEventListener('pointerdown', onDocumentPointerDown, true)
438
478
  document.removeEventListener('scroll', onScroll, true)
479
+ removeEmbeddedDocumentListeners()
439
480
  openListenersActive = false
440
481
  }
441
482
 
442
483
  onMounted(() => {
443
484
  if (props.showGroup) {
444
485
  if (!groupRegistry.has(props.showGroup)) groupRegistry.set(props.showGroup, new Set())
445
- groupRegistry.get(props.showGroup)!.add(internalHide)
486
+ groupRegistry.get(props.showGroup)!.add(hideForGroup)
446
487
  }
447
488
  if (shown.value) internalShow()
448
489
  })
@@ -450,7 +491,7 @@ onMounted(() => {
450
491
  onBeforeUnmount(() => {
451
492
  removeOpenListeners()
452
493
  clearTimers()
453
- if (props.showGroup) groupRegistry.get(props.showGroup)?.delete(internalHide)
494
+ if (props.showGroup) groupRegistry.get(props.showGroup)?.delete(hideForGroup)
454
495
  if (isOpen()) popoverRef.value?.hidePopover()
455
496
  })
456
497
 
@@ -482,7 +523,7 @@ defineExpose({ show, hide, toggle, openAt, updatePosition, shown })
482
523
  </div>
483
524
 
484
525
  <!-- Popup — teleported to closest dialog[open] or body, promoted to top layer via popover -->
485
- <Teleport :to="teleportTarget">
526
+ <Teleport v-if="hasOpened" :to="teleportTarget">
486
527
  <div
487
528
  v-if="hasOpened"
488
529
  :id="bglId" ref="popoverRef" popover="manual" class="bgl-dropdown m-0 p-0 border-none"
@@ -489,7 +489,7 @@ function onConnectorChange(id: string, connector: LogicalOperator) {
489
489
  <TransitionGroup name="condition">
490
490
  <div
491
491
  v-for="(condition, index) in conditions" :key="condition.id"
492
- class="grid gap-025 align-items-center pt-025" :class="{
492
+ class="grid gap-025 align-items-center py-025" :class="{
493
493
  '': index > 0 && condition.connector === 'or',
494
494
  'pt-075 border-top-or': index > 0 && condition.connector === 'or',
495
495
  'first-filter-row': index === 0,
@@ -4,6 +4,8 @@ import { useDevice } from '../../index'
4
4
  import { computed, ref, onMounted, watchEffect } from 'vue'
5
5
  import { MATERIAL_ICONS, FONT_AWESOME_BRANDS_ICONS } from './constants'
6
6
 
7
+ defineOptions({ inheritAttrs: false })
8
+
7
9
  type AnimateType = 'spin' | 'pulse' | 'bounce' | 'fade-in' | 'slide-up' | 'rotate-in'
8
10
  type TransitionType = 'slide-up' | 'rotate' | 'scale' | 'fade'
9
11
 
@@ -192,6 +194,7 @@ const iconStyle = computed(() => {
192
194
  <template>
193
195
  <Transition :name="transitionName" mode="out-in">
194
196
  <span
197
+ v-bind="$attrs"
195
198
  v-if="iconRenderType === 'material'" :key="`material-${iconRender}`" class="bgl_icon-font notranslate"
196
199
  :class="[animateClass, roundClass, roundPairClass, softClass, { 'color-white keep-white': round && !soft && isRawCssColor }]"
197
200
  :style="iconStyle" aria-hidden="true" translate="no"
@@ -199,6 +202,7 @@ const iconStyle = computed(() => {
199
202
  {{ iconRender }}
200
203
  </span>
201
204
  <span
205
+ v-bind="$attrs"
202
206
  v-else-if="iconRenderType === 'font-awesome-brand'" :key="`font-awesome-brand-${iconRender}`" class="fa fa-brands bgl_icon-font notranslate" :class="[
203
207
  `fa-${iconRender}`,
204
208
  animateClass,
@@ -250,6 +254,10 @@ const iconStyle = computed(() => {
250
254
  font-family: 'Font Awesome 6 Brands', serif !important;
251
255
  }
252
256
 
257
+ .bgl_icon-font.fa-brands::before {
258
+ content: var(--fa);
259
+ }
260
+
253
261
  /* ── Entrance / loop animations (opt-in via `animate`) ─────────────────────── */
254
262
  .bgl_icon--spin,
255
263
  .bgl_icon--pulse,
@@ -208,7 +208,10 @@ const headerFontSize = computed(() => {
208
208
  <tr
209
209
  v-for="{ data: row } in list" :key="row?.id || `row-${Math.random()}`"
210
210
  class="row row-item position-relative"
211
- :class="{ selected: row?.id && computedSelectedItems.includes(row.id) }"
211
+ :class="{
212
+ selected: row?.id != null
213
+ && computedSelectedItems.map(String).includes(String(row.id)),
214
+ }"
212
215
  @click="(event) => toggleSelectItem(row, event)"
213
216
  >
214
217
  <td v-if="isSortable" class="col-drag">
@@ -295,11 +298,11 @@ min-height: 150px;
295
298
  overflow-anchor: none;
296
299
  }
297
300
 
298
- .selected {
301
+ .selected > td {
299
302
  background: var(--bgl-primary-tint);
300
303
  }
301
304
 
302
- tbody tr.selected:hover {
305
+ tbody tr.selected:hover > td {
303
306
  background: var(--bgl-primary-light);
304
307
  }
305
308
 
@@ -1,8 +1,9 @@
1
1
  <script lang="ts" setup>
2
2
  import type { ToolbarConfig, ToolbarConfigOption, ToolbarOption } from '../richTextTypes'
3
- import { Btn, Divider, Dropdown } from '../../../../../index'
3
+ import { Btn, ColorInput, Divider, Dropdown } from '../../../../../index'
4
4
  import { basicToolbarConfig, toolbarOptions } from '../config'
5
5
  import TableGridSelector from './TableGridSelector.vue'
6
+ import { onBeforeUnmount, ref } from 'vue'
6
7
 
7
8
  const {
8
9
  config = basicToolbarConfig,
@@ -14,7 +15,8 @@ const {
14
15
  hideAlignment = false,
15
16
  hideDirections = false,
16
17
  hideH5H6 = false,
17
- hide = []
18
+ hide = [],
19
+ documentColors = [],
18
20
  } = defineProps<{
19
21
  config?: ToolbarConfig
20
22
  selectedStyles: Set<string>
@@ -26,8 +28,23 @@ const {
26
28
  hideDirections?: boolean
27
29
  hideH5H6?: boolean
28
30
  hide?: string[]
31
+ documentColors?: string[]
29
32
  }>()
30
33
  const emit = defineEmits(['action'])
34
+ const fontColor = ref('#000000')
35
+ const backgroundColor = ref('#fff59d')
36
+ const basicColors = [
37
+ '#000000',
38
+ '#ffffff',
39
+ '#6b7280',
40
+ '#ef4444',
41
+ '#f97316',
42
+ '#eab308',
43
+ '#22c55e',
44
+ '#3b82f6',
45
+ '#8b5cf6',
46
+ ]
47
+ let colorCloseTimer: ReturnType<typeof setTimeout> | null = null
31
48
 
32
49
  function getCurrentAlignmentIcon(): string {
33
50
  if (selectedStyles.has('alignLeft')) { return 'format_align_left' }
@@ -58,8 +75,8 @@ function shouldHideAction(actionName: string): boolean {
58
75
  ol: ['orderedList'],
59
76
  splitView: ['splitView'],
60
77
  p: ['p'],
61
- align: ['alignMenu', 'alignLeft', 'alignCenter', 'alignRight', 'alignJustify'],
62
- alignment: ['alignMenu', 'alignLeft', 'alignCenter', 'alignRight', 'alignJustify']
78
+ align: ['alignMenu', 'paragraphToolsMenu', 'alignLeft', 'alignCenter', 'alignRight', 'alignJustify'],
79
+ alignment: ['alignMenu', 'paragraphToolsMenu', 'alignLeft', 'alignCenter', 'alignRight', 'alignJustify']
63
80
  }
64
81
 
65
82
  for (const [hideKey, actionNames] of Object.entries(alternativeNames)) {
@@ -77,6 +94,7 @@ function shouldHideAction(actionName: string): boolean {
77
94
  embed: hideEmbeds,
78
95
  insertTable: hideTables,
79
96
  alignMenu: hideAlignment,
97
+ paragraphToolsMenu: hideAlignment,
80
98
  alignLeft: hideAlignment,
81
99
  alignCenter: hideAlignment,
82
100
  alignRight: hideAlignment,
@@ -93,6 +111,41 @@ function shouldHideAction(actionName: string): boolean {
93
111
 
94
112
  const configToOption = (action: ToolbarConfigOption) => toolbarOptions.find(option => option.name === action) as ToolbarOption
95
113
 
114
+ function getActionLabel(action: ToolbarOption): string {
115
+ const state = selectedStyles.has(action.name) ? 'Active' : 'Inactive'
116
+ return [action.label || action.name, action.shortcut, state].filter(Boolean).join(' · ')
117
+ }
118
+
119
+ function getToolbarControls(toolbar: HTMLElement): HTMLElement[] {
120
+ return Array.from(toolbar.querySelectorAll<HTMLElement>(
121
+ 'button:not(:disabled), [role="button"]:not([aria-disabled="true"]), input:not(:disabled), select:not(:disabled)'
122
+ )).filter(element => element.offsetParent !== null || element.getClientRects().length > 0)
123
+ }
124
+
125
+ function handleToolbarKeydown(event: KeyboardEvent) {
126
+ if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) { return }
127
+ const toolbar = event.currentTarget as HTMLElement
128
+ const controls = getToolbarControls(toolbar)
129
+ if (!controls.length) { return }
130
+
131
+ const currentIndex = controls.indexOf(event.target as HTMLElement)
132
+ if (currentIndex < 0) { return }
133
+
134
+ event.preventDefault()
135
+ const isRtl = getComputedStyle(toolbar).direction === 'rtl'
136
+ let nextIndex = currentIndex
137
+ if (event.key === 'Home') {
138
+ nextIndex = 0
139
+ } else if (event.key === 'End') {
140
+ nextIndex = controls.length - 1
141
+ } else {
142
+ const visualDirection = event.key === 'ArrowRight' ? 1 : -1
143
+ const direction = isRtl ? -visualDirection : visualDirection
144
+ nextIndex = (currentIndex + direction + controls.length) % controls.length
145
+ }
146
+ controls[nextIndex]?.focus()
147
+ }
148
+
96
149
  function shouldShowSeparator(currentIndex: number): boolean {
97
150
  const allActions = config.map(configToOption).filter(Boolean)
98
151
 
@@ -139,14 +192,122 @@ function handleQuickTableInsert(rows: number, cols: number) {
139
192
  function handleOpenAdvanced() {
140
193
  emit('action', 'insertTable')
141
194
  }
195
+
196
+ function scheduleColorMenuClose(closeMenu: () => void) {
197
+ if (colorCloseTimer) {
198
+ clearTimeout(colorCloseTimer)
199
+ }
200
+ colorCloseTimer = setTimeout(() => {
201
+ closeMenu()
202
+ colorCloseTimer = null
203
+ }, 4000)
204
+ }
205
+
206
+ function applyColor(command: 'fontColor' | 'bgColor', value: string, closeMenu: () => void) {
207
+ if (value) {
208
+ runAction(command, value)
209
+ }
210
+ scheduleColorMenuClose(closeMenu)
211
+ }
212
+
213
+ function applyRecentColor(command: string, value: string, closeMenu: () => void) {
214
+ if (command !== 'fontColor' && command !== 'bgColor') { return }
215
+ if (command === 'fontColor') {
216
+ fontColor.value = value
217
+ } else {
218
+ backgroundColor.value = value
219
+ }
220
+ applyColor(command, value, closeMenu)
221
+ }
222
+
223
+ onBeforeUnmount(() => {
224
+ if (colorCloseTimer) {
225
+ clearTimeout(colorCloseTimer)
226
+ }
227
+ })
142
228
  </script>
143
229
 
144
230
  <template>
145
- <div class="toolbar flex gap-025 pb-05 flex-wrap" role="toolbar" style="position: relative;">
231
+ <div
232
+ class="toolbar flex gap-025 pb-05 flex-wrap m_flex-wrap-nowrap overflow-x"
233
+ role="toolbar"
234
+ aria-label="Rich text formatting"
235
+ style="position: relative;"
236
+ @keydown="handleToolbarKeydown"
237
+ >
146
238
  <template v-for="(action, index) in config.map(configToOption).filter(Boolean)" :key="index">
147
239
  <!-- Tables -->
148
240
  <TableGridSelector v-if="action.name === 'insertTable' && !shouldHideAction('insertTable')" @insert="handleQuickTableInsert" @open-advanced="handleOpenAdvanced" />
149
241
 
242
+ <!-- Table editing tools -->
243
+ <Dropdown
244
+ v-else-if="action.name === 'tableToolsMenu' && !hideTables"
245
+ placement="bottom-start"
246
+ thin
247
+ flat
248
+ icon="table_edit"
249
+ aria-label="Table tools"
250
+ :disabled="!selectedStyles.has('tableContext')"
251
+ :class="{ 'opacity-4': !selectedStyles.has('tableContext') }"
252
+ :title="selectedStyles.has('tableContext') ? 'Table tools' : 'Select a table cell to use table tools'"
253
+ class="table-tools-menu"
254
+ >
255
+ <template #default="{ hide: closeMenu }">
256
+ <div class="grid min-w-220px p-025">
257
+ <span class="txt12 semi color-gray px-05 py-025">Table tools</span>
258
+ <Btn thin flat full-width align-txt="start" icon="table_chart" value="Merge selected cells" @click="runAction('mergeCells'); closeMenu()" />
259
+ <Btn thin flat full-width align-txt="start" icon="dashboard" value="Split cell" @click="runAction('splitCells'); closeMenu()" />
260
+ <Divider class="my-025" />
261
+ <Btn thin flat full-width align-txt="start" icon="add_row_above" value="Insert row above" @click="runAction('addRowBefore'); closeMenu()" />
262
+ <Btn thin flat full-width align-txt="start" icon="add_row_below" value="Insert row below" @click="runAction('addRowAfter'); closeMenu()" />
263
+ <Btn thin flat full-width align-txt="start" icon="remove" value="Delete row" @click="runAction('deleteRow'); closeMenu()" />
264
+ <Divider class="my-025" />
265
+ <Btn thin flat full-width align-txt="start" icon="add_column_left" value="Insert column left" @click="runAction('insertColumnLeft'); closeMenu()" />
266
+ <Btn thin flat full-width align-txt="start" icon="add_column_right" value="Insert column right" @click="runAction('insertColumnRight'); closeMenu()" />
267
+ <Btn thin flat full-width align-txt="start" icon="delete" value="Delete column" @click="runAction('deleteColumn'); closeMenu()" />
268
+ <Divider class="my-025" />
269
+ <span class="txt12 semi color-gray px-05 py-025">Column alignment</span>
270
+ <div class="flex gap-025 px-025 pb-025">
271
+ <Btn thin flat icon="format_align_left" aria-label="Align table column left" @click="runAction('tableAlignLeft'); closeMenu()" />
272
+ <Btn thin flat icon="format_align_center" aria-label="Align table column center" @click="runAction('tableAlignCenter'); closeMenu()" />
273
+ <Btn thin flat icon="format_align_right" aria-label="Align table column right" @click="runAction('tableAlignRight'); closeMenu()" />
274
+ <Btn thin flat icon="format_align_justify" aria-label="Justify table column" @click="runAction('tableAlignJustify'); closeMenu()" />
275
+ </div>
276
+ <Divider class="my-025" />
277
+ <Btn thin flat full-width align-txt="start" color="red" icon="delete_sweep" value="Delete table" @click="runAction('deleteTable'); closeMenu()" />
278
+ </div>
279
+ </template>
280
+ </Dropdown>
281
+
282
+ <!-- Paragraph alignment and direction -->
283
+ <Dropdown
284
+ v-else-if="action.name === 'paragraphToolsMenu' && !shouldHideAction('paragraphToolsMenu')"
285
+ placement="bottom-start"
286
+ thin
287
+ flat
288
+ :icon="getCurrentAlignmentIcon()"
289
+ :class="{ 'alignment-active': isAlignmentActive() }"
290
+ aria-label="Paragraph tools"
291
+ >
292
+ <template #default="{ hide: closeMenu }">
293
+ <div class="grid min-w-220px p-025">
294
+ <span class="txt12 semi color-gray px-05 py-025">Paragraph alignment</span>
295
+ <div class="flex gap-025 px-025 pb-025">
296
+ <Btn thin flat icon="format_align_left" aria-label="Align paragraph left" :class="{ active: selectedStyles.has('alignLeft') }" @click="runAction('alignLeft'); closeMenu()" />
297
+ <Btn thin flat icon="format_align_center" aria-label="Align paragraph center" :class="{ active: selectedStyles.has('alignCenter') }" @click="runAction('alignCenter'); closeMenu()" />
298
+ <Btn thin flat icon="format_align_right" aria-label="Align paragraph right" :class="{ active: selectedStyles.has('alignRight') }" @click="runAction('alignRight'); closeMenu()" />
299
+ <Btn thin flat icon="format_align_justify" aria-label="Justify paragraph" :class="{ active: selectedStyles.has('alignJustify') }" @click="runAction('alignJustify'); closeMenu()" />
300
+ </div>
301
+ <Divider class="my-025" />
302
+ <span class="txt12 semi color-gray px-05 py-025">Writing direction</span>
303
+ <div class="flex gap-025 px-025 pb-025">
304
+ <Btn thin flat icon="format_textdirection_l_to_r" value="LTR" aria-label="Left to right" :class="{ active: selectedStyles.has('ltrDirection') }" @click="runAction('ltrDirection'); closeMenu()" />
305
+ <Btn thin flat icon="format_textdirection_r_to_l" value="RTL" aria-label="Right to left" :class="{ active: selectedStyles.has('rtlDirection') }" @click="runAction('rtlDirection'); closeMenu()" />
306
+ </div>
307
+ </div>
308
+ </template>
309
+ </Dropdown>
310
+
150
311
  <!-- Alignment Menu -->
151
312
  <Dropdown
152
313
  v-else-if="action.name === 'alignMenu' && !shouldHideAction('alignMenu')" placement="bottom-start" thin flat :icon="getCurrentAlignmentIcon()"
@@ -169,20 +330,83 @@ function handleOpenAdvanced() {
169
330
  :class="[action.class, { active: selectedStyles.has('textDirection') }]" tabindex="-1" @click="runAction(action.name)"
170
331
  />
171
332
 
333
+ <!-- Text / background colors -->
334
+ <Dropdown
335
+ v-else-if="(action.name === 'fontColor' || action.name === 'bgColor') && !shouldHideAction(action.name)"
336
+ placement="bottom-start"
337
+ thin
338
+ flat
339
+ :icon="action.icon"
340
+ >
341
+ <template #default="{ hide: closeMenu }">
342
+ <div class="px-05 min-w-220px" @pointerdown="scheduleColorMenuClose(closeMenu)" @keydown="scheduleColorMenuClose(closeMenu)">
343
+ <ColorInput
344
+ v-if="action.name === 'fontColor'"
345
+ v-model="fontColor"
346
+ label="Text color"
347
+ @update:model-value="applyColor('fontColor', $event, closeMenu)"
348
+ />
349
+ <ColorInput
350
+ v-else
351
+ v-model="backgroundColor"
352
+ label="Background color"
353
+ @update:model-value="applyColor('bgColor', $event, closeMenu)"
354
+ />
355
+ <Divider label="Basic colors" class="py-025" />
356
+ <div class="flex flex-wrap gap-025">
357
+ <Btn
358
+ v-for="color in basicColors"
359
+ :key="color"
360
+ round
361
+ flat
362
+ thin
363
+ class="square-24 border"
364
+ :style="{ backgroundColor: color }"
365
+ :aria-label="`Use ${color}`"
366
+ :title="color"
367
+ @click="applyRecentColor(action.name, color, closeMenu)"
368
+ />
369
+ </div>
370
+ <template v-if="documentColors.length">
371
+ <Divider label="Colors in document" class="py-025" />
372
+ <div class="flex flex-wrap gap-025">
373
+ <Btn
374
+ v-for="color in documentColors"
375
+ :key="color"
376
+ round
377
+ flat
378
+ thin
379
+ class="square-24 border"
380
+ :style="{ backgroundColor: color }"
381
+ :aria-label="`Use ${color}`"
382
+ :title="color"
383
+ @click="applyRecentColor(action.name, color, closeMenu)"
384
+ />
385
+ </div>
386
+ </template>
387
+ </div>
388
+ </template>
389
+ </Dropdown>
390
+
172
391
  <!-- All other buttons -->
173
392
  <Btn
174
393
  v-else-if="action.name !== 'separator'
175
394
  && action.name !== 'insertTable'
395
+ && action.name !== 'tableToolsMenu'
396
+ && action.name !== 'paragraphToolsMenu'
176
397
  && action.name !== 'alignMenu'
177
398
  && action.name !== 'textDirection'
399
+ && action.name !== 'fontColor'
400
+ && action.name !== 'bgColor'
178
401
  && !shouldHideAction(action.name)"
179
- v-tooltip="action.label"
180
402
  :icon="action.icon"
181
403
  thin
182
404
  flat
183
- :aria-label="action.name"
405
+ :aria-label="getActionLabel(action)"
184
406
  :class="[action.class, { active: selectedStyles.has(action.name) }]"
185
- tabindex="-1"
407
+ :aria-pressed="selectedStyles.has(action.name)"
408
+ :title="getActionLabel(action)"
409
+ v-tooltip="getActionLabel(action)"
186
410
  @click="runAction(action.name)"
187
411
  />
188
412
 
@@ -194,6 +418,12 @@ function handleOpenAdvanced() {
194
418
  </template>
195
419
 
196
420
  <style scoped>
421
+ .toolbar {
422
+ scrollbar-width: thin;
423
+ }
424
+ .toolbar > * {
425
+ flex-shrink: 0;
426
+ }
197
427
  .toolbar :deep(.active) {
198
428
  background: var(--bgl-primary);
199
429
  color: white !important;
@@ -18,6 +18,10 @@ export function useCommands(state: EditorState, debug?: { logCommand: (command:
18
18
  }
19
19
 
20
20
  debug?.logCommand(command, value)
21
+ const activeSelection = state.doc.getSelection()
22
+ const preFocusRange = activeSelection?.rangeCount
23
+ ? activeSelection.getRangeAt(0).cloneRange()
24
+ : state.range?.cloneRange()
21
25
 
22
26
  if (['splitView', 'codeView', 'fullScreen'].includes(command)) {
23
27
  switch (command) {
@@ -33,16 +37,30 @@ export function useCommands(state: EditorState, debug?: { logCommand: (command:
33
37
  }
34
38
  }
35
39
 
36
- try {
37
- state.doc.body.focus({ preventScroll: true })
38
- } catch {
39
- // Ignore focus errors
40
+ const selectionIsInsideEditor = Boolean(activeSelection?.anchorNode
41
+ && state.doc.body.contains(activeSelection.anchorNode))
42
+ if (!selectionIsInsideEditor) {
43
+ try {
44
+ state.doc.body.focus({ preventScroll: true })
45
+ } catch {
46
+ // Ignore focus errors
47
+ }
40
48
  }
41
49
 
42
50
  // Restore selection if focusing the body cleared or collapsed the user's active selection
43
51
  const selection = state.doc.getSelection()
44
52
  if (selection) {
45
- if (selection.rangeCount > 0) {
53
+ if (preFocusRange) {
54
+ try {
55
+ selection.removeAllRanges()
56
+ selection.addRange(preFocusRange)
57
+ state.selection = selection
58
+ state.range = preFocusRange.cloneRange()
59
+ state.rangeCount = selection.rangeCount
60
+ } catch {
61
+ // Ignore invalid ranges after external DOM changes
62
+ }
63
+ } else if (selection.rangeCount > 0) {
46
64
  const currentRange = selection.getRangeAt(0)
47
65
  if (currentRange.collapsed && state.range && !state.range.collapsed) {
48
66
  try {
@@ -43,6 +43,34 @@ export function useEditorKeyboard(doc: Document, executor: CommandExecutor): ()
43
43
  const range = selection?.rangeCount ? selection.getRangeAt(0) : null
44
44
  const activeCell = range ? resolveCellFromNode(range.startContainer) : null
45
45
 
46
+ const startElement = range?.startContainer.nodeType === Node.TEXT_NODE
47
+ ? range.startContainer.parentElement
48
+ : range?.startContainer as Element | undefined
49
+ const activeListItem = (startElement?.matches?.('li')
50
+ ? startElement
51
+ : startElement?.closest('li')) as HTMLLIElement | null
52
+
53
+ if (e.key === 'Tab' && activeListItem && !activeCell) {
54
+ e.preventDefault()
55
+ executor.execute(e.shiftKey ? 'outdent' : 'indent')
56
+ return
57
+ }
58
+
59
+ const parentList = activeListItem?.parentElement
60
+ const isNestedListItem = Boolean(parentList && ['UL', 'OL'].includes(parentList.tagName)
61
+ && parentList.closest('li'))
62
+ if (e.key === 'Enter' && activeListItem && !activeCell
63
+ && !activeListItem.textContent?.trim()
64
+ && isNestedListItem) {
65
+ e.preventDefault()
66
+ const itemRange = doc.createRange()
67
+ itemRange.selectNodeContents(activeListItem)
68
+ selection?.removeAllRanges()
69
+ selection?.addRange(itemRange)
70
+ executor.execute('outdent')
71
+ return
72
+ }
73
+
46
74
  // Table Tab / Shift+Tab navigation
47
75
  if (e.key === 'Tab' && activeCell) {
48
76
  const table = activeCell.closest('table') as HTMLTableElement | null