@bagelink/vue 1.18.7 → 1.19.3

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 (63) hide show
  1. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  2. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  3. package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
  4. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts +1 -0
  5. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
  6. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
  7. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
  8. package/dist/components/form/inputs/RichText/config.d.ts.map +1 -1
  9. package/dist/components/form/inputs/RichText/index.vue.d.ts +6 -0
  10. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  11. package/dist/components/form/inputs/RichText/richTextTypes.d.ts +1 -0
  12. package/dist/components/form/inputs/RichText/richTextTypes.d.ts.map +1 -1
  13. package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
  14. package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts +7 -0
  15. package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts.map +1 -0
  16. package/dist/components/form/inputs/RichText/utils/image.d.ts +17 -0
  17. package/dist/components/form/inputs/RichText/utils/image.d.ts.map +1 -0
  18. package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts +15 -0
  19. package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts.map +1 -0
  20. package/dist/components/form/inputs/RichText/utils/selection.d.ts +1 -1
  21. package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
  22. package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
  23. package/dist/components/form/inputs/RichText/utils/video.d.ts +23 -0
  24. package/dist/components/form/inputs/RichText/utils/video.d.ts.map +1 -0
  25. package/dist/components/form/inputs/SelectBtn.vue.d.ts +28 -1
  26. package/dist/components/form/inputs/SelectBtn.vue.d.ts.map +1 -1
  27. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -0
  28. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  29. package/dist/components/form/inputs/Upload/useFileUpload.d.ts +1 -0
  30. package/dist/components/form/inputs/Upload/useFileUpload.d.ts.map +1 -1
  31. package/dist/core.css +2 -2
  32. package/dist/index.cjs +218 -130
  33. package/dist/index.css +1 -1
  34. package/dist/index.mjs +18662 -17635
  35. package/dist/style.css +2 -2
  36. package/package.json +1 -1
  37. package/src/components/Dropdown.vue +30 -0
  38. package/src/components/FilterQuery.vue +1 -1
  39. package/src/components/Icon/Icon.vue +4 -0
  40. package/src/components/dataTable/DataTable.vue +6 -3
  41. package/src/components/form/inputs/RichText/components/EditorToolbar.vue +238 -8
  42. package/src/components/form/inputs/RichText/composables/useCommands.ts +23 -5
  43. package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +28 -0
  44. package/src/components/form/inputs/RichText/config.ts +17 -14
  45. package/src/components/form/inputs/RichText/index.vue +381 -478
  46. package/src/components/form/inputs/RichText/richTextTypes.ts +1 -0
  47. package/src/components/form/inputs/RichText/utils/commands.ts +437 -24
  48. package/src/components/form/inputs/RichText/utils/contentMetrics.ts +24 -0
  49. package/src/components/form/inputs/RichText/utils/image.ts +136 -0
  50. package/src/components/form/inputs/RichText/utils/sanitizePaste.ts +110 -0
  51. package/src/components/form/inputs/RichText/utils/selection.ts +32 -0
  52. package/src/components/form/inputs/RichText/utils/table.ts +77 -8
  53. package/src/components/form/inputs/RichText/utils/video.ts +270 -0
  54. package/src/components/form/inputs/SelectBtn.vue +126 -15
  55. package/src/components/form/inputs/TelInput.vue +1 -1
  56. package/src/components/form/inputs/Upload/UploadInput.vue +3 -2
  57. package/src/components/form/inputs/Upload/useFileUpload.ts +2 -0
  58. package/src/styles/colors.css +5 -2
  59. package/src/styles/core.css +1 -0
  60. package/src/styles/rich-content.css +109 -0
  61. package/dist/components/form/inputs/RichText/utils/linkPolicy.d.ts +0 -11
  62. package/dist/components/form/inputs/RichText/utils/linkPolicy.d.ts.map +0 -1
  63. package/src/components/form/inputs/RichText/utils/linkPolicy.ts +0 -48
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.18.7",
4
+ "version": "1.19.3",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -347,6 +347,12 @@ function onDocumentPointerDown(e: PointerEvent) {
347
347
  internalHide({ restoreFocus: false })
348
348
  }
349
349
 
350
+ function onEmbeddedDocumentPointerDown() {
351
+ if (props.autoHide && isOpen()) {
352
+ internalHide({ restoreFocus: false })
353
+ }
354
+ }
355
+
350
356
  // Esc key — LIFO via shared composable (nested layers close first)
351
357
  useEscapeKey(() => internalHide(), () => isOpen())
352
358
 
@@ -425,10 +431,33 @@ function onScroll() {
425
431
  }
426
432
 
427
433
  let openListenersActive = false
434
+ const embeddedDocuments = new Set<Document>()
435
+
436
+ function addEmbeddedDocumentListeners() {
437
+ document.querySelectorAll('iframe').forEach((iframe) => {
438
+ try {
439
+ const embeddedDocument = iframe.contentDocument
440
+ if (!embeddedDocument || embeddedDocuments.has(embeddedDocument)) return
441
+ embeddedDocument.addEventListener('pointerdown', onEmbeddedDocumentPointerDown, true)
442
+ embeddedDocuments.add(embeddedDocument)
443
+ } catch {
444
+ // Cross-origin iframes are not inspectable; the browser owns their focus boundary.
445
+ }
446
+ })
447
+ }
448
+
449
+ function removeEmbeddedDocumentListeners() {
450
+ embeddedDocuments.forEach((embeddedDocument) => {
451
+ embeddedDocument.removeEventListener('pointerdown', onEmbeddedDocumentPointerDown, true)
452
+ })
453
+ embeddedDocuments.clear()
454
+ }
455
+
428
456
  function addOpenListeners() {
429
457
  if (openListenersActive) return
430
458
  document.addEventListener('pointerdown', onDocumentPointerDown, true)
431
459
  document.addEventListener('scroll', onScroll, { capture: true, passive: true })
460
+ addEmbeddedDocumentListeners()
432
461
  openListenersActive = true
433
462
  }
434
463
 
@@ -436,6 +465,7 @@ function removeOpenListeners() {
436
465
  if (!openListenersActive) return
437
466
  document.removeEventListener('pointerdown', onDocumentPointerDown, true)
438
467
  document.removeEventListener('scroll', onScroll, true)
468
+ removeEmbeddedDocumentListeners()
439
469
  openListenersActive = false
440
470
  }
441
471
 
@@ -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,
@@ -250,6 +250,10 @@ const iconStyle = computed(() => {
250
250
  font-family: 'Font Awesome 6 Brands', serif !important;
251
251
  }
252
252
 
253
+ .bgl_icon-font.fa-brands::before {
254
+ content: var(--fa);
255
+ }
256
+
253
257
  /* ── Entrance / loop animations (opt-in via `animate`) ─────────────────────── */
254
258
  .bgl_icon--spin,
255
259
  .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
@@ -33,9 +33,7 @@ export const basicToolbarConfig: ToolbarConfig = [
33
33
  'italic',
34
34
  'underline',
35
35
  'separator',
36
- 'alignMenu',
37
- 'ltrDirection',
38
- 'rtlDirection',
36
+ 'paragraphToolsMenu',
39
37
  'separator',
40
38
  'insertTable',
41
39
  'separator',
@@ -67,6 +65,8 @@ export const fullToolbarConfig: ToolbarConfig = [
67
65
  'fontColor',
68
66
  'bgColor',
69
67
  'separator',
68
+ 'paragraphToolsMenu',
69
+ 'separator',
70
70
  'p',
71
71
  'blockquote',
72
72
  'orderedList',
@@ -81,8 +81,9 @@ export const fullToolbarConfig: ToolbarConfig = [
81
81
  'separator',
82
82
  'splitView',
83
83
  'clear',
84
+ 'separator',
84
85
  'insertTable',
85
- ...tableTools,
86
+ 'tableToolsMenu',
86
87
  'separator',
87
88
  'undo',
88
89
  'redo',
@@ -93,6 +94,8 @@ export const toolbarOptions: ToolbarOption[] = [
93
94
  { name: 'headingsMenu', label: 'Headings', icon: 'title' },
94
95
  { name: 'mediaMenu', label: 'Media', icon: 'perm_media' },
95
96
  { name: 'alignMenu', label: 'Alignment', icon: 'format_align_left' },
97
+ { name: 'paragraphToolsMenu', label: 'Paragraph tools', icon: 'format_align_right' },
98
+ { name: 'tableToolsMenu', label: 'Table tools', icon: 'table_edit' },
96
99
  { name: 'h1', label: 'Heading 1', icon: 'format_h1' },
97
100
  { name: 'h2', label: 'Heading 2', icon: 'format_h2' },
98
101
  { name: 'h3', label: 'Heading 3', icon: 'format_h3' },
@@ -101,12 +104,12 @@ export const toolbarOptions: ToolbarOption[] = [
101
104
  { name: 'h6', label: 'Heading 6', icon: 'format_h6' },
102
105
  { name: 'p', label: 'Paragraph', icon: 'format_paragraph' },
103
106
  { name: 'blockquote', label: 'Blockquote', icon: 'format_quote' },
104
- { name: 'bold', label: 'Bold', icon: 'format_bold' },
105
- { name: 'italic', label: 'Italic', icon: 'format_italic' },
106
- { name: 'underline', label: 'Underline', icon: 'format_underlined' },
107
- { name: 'orderedList', label: 'Ordered List', icon: 'format_list_numbered' },
108
- { name: 'unorderedList', label: 'Unordered List', icon: 'format_list_bulleted' },
109
- { name: 'link', label: 'Link', icon: 'add_link' },
107
+ { name: 'bold', label: 'Bold', icon: 'format_bold', shortcut: '⌘/Ctrl+B' },
108
+ { name: 'italic', label: 'Italic', icon: 'format_italic', shortcut: '⌘/Ctrl+I' },
109
+ { name: 'underline', label: 'Underline', icon: 'format_underlined', shortcut: '⌘/Ctrl+U' },
110
+ { name: 'orderedList', label: 'Ordered List', icon: 'format_list_numbered', shortcut: '⌘/Ctrl+Shift+.' },
111
+ { name: 'unorderedList', label: 'Unordered List', icon: 'format_list_bulleted', shortcut: '⌘/Ctrl+Shift+/' },
112
+ { name: 'link', label: 'Link', icon: 'add_link', shortcut: '⌘/Ctrl+K' },
110
113
  { name: 'image', label: 'Image', icon: 'add_photo_alternate' },
111
114
  { name: 'video', label: 'Video', icon: 'play_circle' },
112
115
  { name: 'embed', label: 'Embed', icon: 'media_link' },
@@ -120,8 +123,8 @@ export const toolbarOptions: ToolbarOption[] = [
120
123
  { name: 'tableAlignCenter', label: 'Table Align Center', icon: 'format_align_center' },
121
124
  { name: 'tableAlignRight', label: 'Table Align Right', icon: 'format_align_right' },
122
125
  { name: 'tableAlignJustify', label: 'Table Align Justify', icon: 'format_align_justify' },
123
- { name: 'indent', label: 'Indent', icon: 'format_indent_increase' },
124
- { name: 'outdent', label: 'Outdent', icon: 'format_indent_decrease' },
126
+ { name: 'indent', label: 'Indent', icon: 'format_indent_increase', shortcut: 'Tab' },
127
+ { name: 'outdent', label: 'Outdent', icon: 'format_indent_decrease', shortcut: 'Shift+Tab' },
125
128
  { name: 'fontColor', label: 'Font Color', icon: 'format_color_text' },
126
129
  { name: 'bgColor', label: 'Background Color', icon: 'format_color_fill' },
127
130
  { name: 'insertTable', label: 'Insert Table', icon: 'table' },
@@ -135,8 +138,8 @@ export const toolbarOptions: ToolbarOption[] = [
135
138
  { name: 'insertColumnRight', label: 'Insert Column Right', icon: 'format_indent_increase' },
136
139
  { name: 'deleteColumn', label: 'Delete Column', icon: 'view_week' },
137
140
  { name: 'separator' },
138
- { name: 'undo', label: 'Undo', icon: 'undo' },
139
- { name: 'redo', label: 'Redo', icon: 'redo' },
141
+ { name: 'undo', label: 'Undo', icon: 'undo', shortcut: '⌘/Ctrl+Z' },
142
+ { name: 'redo', label: 'Redo', icon: 'redo', shortcut: '⌘/Ctrl+Shift+Z' },
140
143
  { name: 'separator' },
141
144
  { name: 'fullScreen', label: 'Full Screen', icon: 'fullscreen', class: 'ms-auto' },
142
145
  { name: 'textDirection', label: 'Text Direction (RTL/LTR)', icon: 'format_textdirection_r_to_l' },