@bagelink/vue 1.17.2 → 1.18.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 (97) hide show
  1. package/dist/assetTransport.d.ts +9 -31
  2. package/dist/assetTransport.d.ts.map +1 -1
  3. package/dist/assets.d.ts +39 -20
  4. package/dist/assets.d.ts.map +1 -1
  5. package/dist/components/AddressSearch.vue.d.ts +40 -36
  6. package/dist/components/AddressSearch.vue.d.ts.map +1 -1
  7. package/dist/components/ContextMenu.vue.d.ts +518 -0
  8. package/dist/components/ContextMenu.vue.d.ts.map +1 -0
  9. package/dist/components/Dropdown.types.d.ts +25 -0
  10. package/dist/components/Dropdown.types.d.ts.map +1 -0
  11. package/dist/components/Dropdown.vue.d.ts +20 -15
  12. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  13. package/dist/components/FilterQuery.vue.d.ts +1 -1
  14. package/dist/components/FilterQuery.vue.d.ts.map +1 -1
  15. package/dist/components/Icon/Icon.vue.d.ts +0 -1
  16. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  17. package/dist/components/Icon/constants.d.ts +0 -1
  18. package/dist/components/Icon/constants.d.ts.map +1 -1
  19. package/dist/components/Icon/types.d.ts +2 -3
  20. package/dist/components/Icon/types.d.ts.map +1 -1
  21. package/dist/components/Swiper.vue.d.ts +1 -1
  22. package/dist/components/draggable/useDraggable.d.ts.map +1 -1
  23. package/dist/components/form/inputs/IconPickerInput.vue.d.ts +48 -37
  24. package/dist/components/form/inputs/IconPickerInput.vue.d.ts.map +1 -1
  25. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
  26. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
  27. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts +3 -0
  28. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts.map +1 -1
  29. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts +1 -1
  30. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
  31. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  32. package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
  33. package/dist/components/form/inputs/RichText/utils/selection.d.ts +2 -0
  34. package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
  35. package/dist/components/form/inputs/RichText/utils/table.d.ts +34 -8
  36. package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
  37. package/dist/components/form/inputs/SelectInput.vue.d.ts +74 -65
  38. package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
  39. package/dist/components/form/inputs/TelInput.vue.d.ts +2 -2
  40. package/dist/components/index.d.ts +3 -1
  41. package/dist/components/index.d.ts.map +1 -1
  42. package/dist/composables/index.d.ts +1 -1
  43. package/dist/composables/index.d.ts.map +1 -1
  44. package/dist/composables/useBoundedRectDrag.d.ts.map +1 -1
  45. package/dist/composables/usePointerSession.d.ts +14 -3
  46. package/dist/composables/usePointerSession.d.ts.map +1 -1
  47. package/dist/core.css +3 -3
  48. package/dist/directives/vResize.d.ts.map +1 -1
  49. package/dist/form-flow/FormFlow.vue.d.ts.map +1 -1
  50. package/dist/form-flow/form-flow.d.ts +4 -2
  51. package/dist/form-flow/form-flow.d.ts.map +1 -1
  52. package/dist/form-flow/schema-fields.d.ts +4 -1
  53. package/dist/form-flow/schema-fields.d.ts.map +1 -1
  54. package/dist/index.cjs +240 -153
  55. package/dist/index.css +1 -1
  56. package/dist/index.mjs +18461 -18283
  57. package/dist/style.css +3 -3
  58. package/dist/types/BagelForm.d.ts +2 -0
  59. package/dist/types/BagelForm.d.ts.map +1 -1
  60. package/package.json +1 -1
  61. package/src/assetTransport.test.ts +33 -83
  62. package/src/assetTransport.ts +24 -90
  63. package/src/assets.test.ts +58 -99
  64. package/src/assets.ts +78 -161
  65. package/src/components/ContextMenu.vue +113 -0
  66. package/src/components/Dropdown.types.ts +27 -0
  67. package/src/components/Dropdown.vue +129 -64
  68. package/src/components/FilterQuery.vue +1 -1
  69. package/src/components/Icon/Icon.vue +8 -30
  70. package/src/components/Icon/constants.ts +0 -166
  71. package/src/components/Icon/types.ts +2 -3
  72. package/src/components/draggable/useDraggable.ts +67 -100
  73. package/src/components/form/inputs/IconPickerInput.vue +14 -9
  74. package/src/components/form/inputs/RichText/components/EditorToolbar.vue +43 -111
  75. package/src/components/form/inputs/RichText/composables/useCommands.ts +32 -54
  76. package/src/components/form/inputs/RichText/composables/useEditor.ts +45 -171
  77. package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +90 -14
  78. package/src/components/form/inputs/RichText/index.vue +1195 -1210
  79. package/src/components/form/inputs/RichText/utils/commands.ts +689 -1028
  80. package/src/components/form/inputs/RichText/utils/selection.ts +66 -41
  81. package/src/components/form/inputs/RichText/utils/table.ts +798 -136
  82. package/src/components/form/inputs/SelectInput.vue +6 -4
  83. package/src/components/form/inputs/Upload/AssetUploadInput.vue +2 -2
  84. package/src/components/index.ts +3 -1
  85. package/src/composables/index.ts +1 -1
  86. package/src/composables/useBoundedRectDrag.ts +9 -12
  87. package/src/composables/usePointerSession.ts +57 -12
  88. package/src/directives/vResize.ts +6 -7
  89. package/src/form-flow/FormFlow.vue +4 -0
  90. package/src/form-flow/form-flow.ts +8 -3
  91. package/src/form-flow/schema-fields.ts +9 -4
  92. package/src/styles/core.css +1 -18
  93. package/src/types/BagelForm.ts +2 -0
  94. package/dist/components/form/inputs/RichText/utils/formatting.d.ts +0 -10
  95. package/dist/components/form/inputs/RichText/utils/formatting.d.ts.map +0 -1
  96. package/src/components/form/inputs/RichText/components/gridBox.vue +0 -58
  97. package/src/components/form/inputs/RichText/utils/formatting.ts +0 -331
@@ -1,7 +1,8 @@
1
1
  <!-- eslint-disable vue/multi-word-component-names -->
2
2
  <script setup lang="ts">
3
3
  import type { CustomToolbarButton, RichTextExtension, RichTextToolbarApi, ToolbarConfig } from './richTextTypes'
4
- import { CodeEditor, copyText, Btn, Dialog, BglVideo, Icon, Card, ColorInput, pathKeyToURL } from '../../../../index'
4
+ import type { DropdownItem } from '../../../../index'
5
+ import { CodeEditor, copyText, Btn, ContextMenu, Dialog, Divider, BglVideo, Icon, Card, ColorInput, pathKeyToURL, usePointerSession } from '../../../../index'
5
6
  import { watch, onUnmounted, onBeforeUnmount, onMounted, ref, computed, useAttrs, inject } from 'vue'
6
7
  import CheckInput from '../CheckInput.vue'
7
8
  import NumberInput from '../NumberInput.vue'
@@ -11,8 +12,21 @@ import TextInput from '../TextInput.vue'
11
12
  import UploadInput from '../Upload/UploadInput.vue'
12
13
  import EditorToolbar from './components/EditorToolbar.vue'
13
14
  import { useCommands } from './composables/useCommands'
14
- import { useEditor } from './composables/useEditor'
15
+ import { pushHistorySnapshot, useEditor } from './composables/useEditor'
15
16
  import { useEditorKeyboard } from './composables/useEditorKeyboard'
17
+ import {
18
+ addRow as tableAddRow,
19
+ buildTableGrid,
20
+ deleteColumn as tableDeleteColumn,
21
+ deleteRow as tableDeleteRow,
22
+ findCellCoords,
23
+ insertColumn as tableInsertColumn,
24
+ mergeCells as tableMergeCells,
25
+ moveColumn as tableMoveColumn,
26
+ removeTableElement,
27
+ splitCell as tableSplitCell,
28
+ syncTableColgroup,
29
+ } from './utils/table'
16
30
  import type { BagelInputShellProps } from '../bagelInputShell'
17
31
  import { useBagelInputShell } from '../bagelInputShell'
18
32
  import { RICH_TEXT_EXTENSION_REGISTRY_KEY } from './richTextExtensions'
@@ -452,63 +466,6 @@ let pendingTableData: {
452
466
  existingTable: HTMLTableElement | null
453
467
  } | null = null
454
468
 
455
- // Computed property for table preview
456
- const tablePreviewHtml = computed(() => {
457
- const form = tableForm.value
458
-
459
- const tableStyle = `
460
- width: ${form.width}%;
461
- border-collapse: collapse;
462
- margin-bottom: 1rem;
463
- border: ${form.borderWidth}px solid ${form.borderColor};
464
- table-layout: ${form.fixedLayout ? 'fixed' : 'auto'};
465
- `
466
-
467
- // For cell text alignment, we'll apply it to individual cells, not the table
468
-
469
- let html = `<table style="${tableStyle}" dir="${form.direction}">`
470
- html += `<colgroup>${form.columnWidths.slice(0, form.cols).map(width => `<col style="width: ${width}%;">`).join('')}</colgroup>`
471
-
472
- // Add header row if enabled
473
- if (form.showHeaders) {
474
- html += '<thead><tr>'
475
- for (let j = 0; j < form.cols; j++) {
476
- const thStyle = `
477
- padding: ${form.cellPadding}px;
478
- border: ${form.borderWidth}px solid ${form.borderColor};
479
- background-color: ${form.headerBgColor};
480
- color: ${form.headerTextColor};
481
- text-align: ${form.alignment};
482
- ${form.fixedLayout ? `width: ${form.columnWidths[j] ?? (100 / form.cols)}%;` : ''}
483
- `
484
- html += `<th style="${thStyle}">Header ${j + 1}</th>`
485
- }
486
- html += '</tr></thead>'
487
- }
488
-
489
- // Add body rows
490
- html += '<tbody>'
491
- for (let i = 0; i < form.rows; i++) {
492
- html += '<tr>'
493
- for (let j = 0; j < form.cols; j++) {
494
- const tdStyle = `
495
- padding: ${form.cellPadding}px;
496
- border: ${form.borderWidth}px solid ${form.borderColor};
497
- text-align: ${form.alignment};
498
- ${form.fixedLayout ? `width: ${form.columnWidths[j] ?? (100 / form.cols)}%;` : ''}
499
- ${form.alternateRows && i % 2 === 1
500
- ? `background-color: ${form.alternateRowBgColor}; color: ${form.alternateRowTextColor};`
501
- : `background-color: ${form.cellBgColor}; color: ${form.cellTextColor};`}
502
- `
503
- html += `<td style="${tdStyle}">Cell ${i + 1},${j + 1}</td>`
504
- }
505
- html += '</tr>'
506
- }
507
- html += '</tbody></table>'
508
-
509
- return html
510
- })
511
-
512
469
  // Initialize content from modelValue
513
470
  editor.state.content = normalizedModelValue.value
514
471
 
@@ -526,7 +483,9 @@ function getTextDirection(text: string, fallback: 'ltr' | 'rtl' = 'ltr'): 'ltr'
526
483
 
527
484
  function getBlockDirection(element: Element, fallback: 'ltr' | 'rtl', preserveExplicit = true): 'ltr' | 'rtl' {
528
485
  const explicitDirection = element.getAttribute('dir')
529
- if (preserveExplicit && (explicitDirection === 'rtl' || explicitDirection === 'ltr')) { return explicitDirection }
486
+ if ((preserveExplicit || element.hasAttribute('data-dir-explicit')) && (explicitDirection === 'rtl' || explicitDirection === 'ltr')) {
487
+ return explicitDirection
488
+ }
530
489
  return getTextDirection(element.textContent || '', fallback)
531
490
  }
532
491
 
@@ -540,7 +499,7 @@ function detectAndSetDirection() {
540
499
 
541
500
  doc.querySelectorAll('p,h1,h2,h3,h4,h5,h6,blockquote,li').forEach((block) => {
542
501
  if (!block.classList.contains('placeholder')) {
543
- block.setAttribute('dir', getBlockDirection(block, fallback, false))
502
+ block.setAttribute('dir', getBlockDirection(block, fallback, true))
544
503
  }
545
504
  })
546
505
  }
@@ -946,22 +905,27 @@ function submitImage() {
946
905
  img.style.height = 'auto'
947
906
  }
948
907
 
949
- // Helper function to create figcaption with alt text and credit
908
+ // Helper function to create figcaption with alt text and credit (using safe DOM nodes)
950
909
  function createFigcaption() {
951
910
  const figcaption = doc!.createElement('figcaption')
952
- let captionHTML = ''
953
911
 
954
- // Add alt text only if figcaption checkbox is checked (with bold styling)
955
912
  if (imageForm.value.figcaption && imageForm.value.alt) {
956
- captionHTML = `<strong class="alt-text">${imageForm.value.alt}</strong>`
913
+ const altStrong = doc!.createElement('strong')
914
+ altStrong.className = 'alt-text'
915
+ altStrong.textContent = imageForm.value.alt
916
+ figcaption.appendChild(altStrong)
957
917
  }
958
918
 
959
- // Always add credit if it exists (with lighter styling)
960
919
  if (imageForm.value.credit) {
961
- captionHTML += `${captionHTML ? ' • ' : ''}<span class="photo-credit">${imageForm.value.credit}</span>`
920
+ if (figcaption.childNodes.length > 0) {
921
+ figcaption.appendChild(doc!.createTextNode(' • '))
922
+ }
923
+ const creditSpan = doc!.createElement('span')
924
+ creditSpan.className = 'photo-credit'
925
+ creditSpan.textContent = imageForm.value.credit
926
+ figcaption.appendChild(creditSpan)
962
927
  }
963
928
 
964
- figcaption.innerHTML = captionHTML
965
929
  return figcaption
966
930
  }
967
931
 
@@ -1461,38 +1425,62 @@ function deleteVideo() {
1461
1425
  // Function to open table editor
1462
1426
  // Function to detect table alignment from table or wrapper
1463
1427
  function detectTableAlignment(table: HTMLTableElement): 'left' | 'center' | 'right' {
1464
- // Check table margins to determine alignment
1465
- const { marginLeft } = table.style
1466
- const { marginRight } = table.style
1428
+ const wrapper = table.closest('.table-wrapper') as HTMLElement | null
1429
+ if (wrapper) {
1430
+ const { alignItems } = wrapper.style
1431
+ if (alignItems === 'center') { return 'center' }
1432
+ if (alignItems === 'flex-end') { return 'right' }
1433
+ if (alignItems === 'flex-start') { return 'left' }
1434
+ }
1467
1435
 
1436
+ const { marginLeft, marginRight } = table.style
1468
1437
  if (marginLeft === 'auto' && marginRight === 'auto') {
1469
1438
  return 'center'
1470
- } if (marginLeft === 'auto' && marginRight === '0') {
1439
+ }
1440
+ if (marginLeft === 'auto' && marginRight === '0') {
1471
1441
  return 'right'
1442
+ }
1443
+ return 'left'
1444
+ }
1445
+
1446
+ function applyTableAlignmentToDOM(table: HTMLTableElement, alignment: 'left' | 'center' | 'right') {
1447
+ const wrapper = table.closest('.table-wrapper') as HTMLElement | null
1448
+ if (wrapper) {
1449
+ if (alignment === 'center') {
1450
+ wrapper.style.alignItems = 'center'
1451
+ } else if (alignment === 'right') {
1452
+ wrapper.style.alignItems = 'flex-end'
1453
+ } else {
1454
+ wrapper.style.alignItems = 'flex-start'
1455
+ }
1456
+ table.style.marginLeft = '0'
1457
+ table.style.marginRight = '0'
1472
1458
  } else {
1473
- return 'left'
1459
+ if (alignment === 'center') {
1460
+ table.style.marginLeft = 'auto'
1461
+ table.style.marginRight = 'auto'
1462
+ } else if (alignment === 'right') {
1463
+ table.style.marginLeft = 'auto'
1464
+ table.style.marginRight = '0'
1465
+ } else {
1466
+ table.style.marginLeft = '0'
1467
+ table.style.marginRight = 'auto'
1468
+ }
1474
1469
  }
1475
1470
  }
1476
1471
 
1477
1472
  function openTableEditor(existingTable: HTMLTableElement | null = null) {
1478
- console.log('[RichText table editor] open requested', {
1479
- hasTable: Boolean(existingTable),
1480
- isFullscreen: editor.state.isFullscreen,
1481
- })
1482
1473
  const { doc } = editor.state
1483
1474
  if (!doc) {
1484
- console.warn('[RichText table editor] aborted: editor document is unavailable')
1485
1475
  return
1486
1476
  }
1487
1477
 
1488
- // Get current selection for new tables
1489
1478
  if (!existingTable) {
1490
1479
  const selection = doc.getSelection()
1491
1480
  if (!selection || !selection.rangeCount) { return }
1492
1481
  const range = selection.getRangeAt(0)
1493
1482
  pendingTableData = { selection, range, existingTable: null }
1494
1483
  } else {
1495
- // For existing tables, set pendingTableData with the existing element
1496
1484
  pendingTableData = {
1497
1485
  selection: doc.getSelection()!,
1498
1486
  range: doc.createRange(),
@@ -1501,13 +1489,11 @@ function openTableEditor(existingTable: HTMLTableElement | null = null) {
1501
1489
  }
1502
1490
 
1503
1491
  if (existingTable) {
1504
- // Populate form with existing table data
1505
1492
  const tbody = existingTable.querySelector('tbody')
1506
1493
  const thead = existingTable.querySelector('thead')
1507
1494
  const editorWindow = existingTable.ownerDocument.defaultView
1508
1495
  const getEditorStyle = (element: Element) => editorWindow?.getComputedStyle(element) ?? null
1509
1496
 
1510
- // Detect alternating rows by comparing background colors
1511
1497
  let hasAlternatingRows = false
1512
1498
  let alternateRowBg = '#f9f9f9'
1513
1499
  let alternateRowTextColor = '#333333'
@@ -1523,7 +1509,6 @@ function openTableEditor(existingTable: HTMLTableElement | null = null) {
1523
1509
  const firstBgColor = getEditorStyle(firstCell)?.backgroundColor || ''
1524
1510
  const secondBgColor = getEditorStyle(secondCell)?.backgroundColor || ''
1525
1511
 
1526
- // Check if colors are different (indicating alternating rows)
1527
1512
  if (firstBgColor !== secondBgColor) {
1528
1513
  hasAlternatingRows = true
1529
1514
  alternateRowBg = secondBgColor
@@ -1532,14 +1517,19 @@ function openTableEditor(existingTable: HTMLTableElement | null = null) {
1532
1517
  }
1533
1518
  }
1534
1519
 
1520
+ const gridMap = buildTableGrid(existingTable)
1521
+ const cols = gridMap.colCount || (tbody && tbody.rows.length > 0 ? tbody.rows[0].cells.length : 3)
1522
+ const firstCellEl = existingTable.querySelector('td, th') as HTMLElement | null
1523
+ const parsedPadding = firstCellEl ? Number.parseInt(firstCellEl.style.padding, 10) : Number.NaN
1524
+
1535
1525
  tableForm.value = {
1536
- rows: tbody ? tbody.rows.length : 3,
1537
- cols: tbody && tbody.rows.length > 0 ? tbody.rows[0].cells.length : 3,
1538
- width: Number.parseInt(existingTable.style.width) || 100,
1539
- borderWidth: Number.parseInt(existingTable.style.borderWidth) || 1,
1526
+ rows: tbody ? tbody.rows.length : Math.max(1, existingTable.rows.length - (thead ? thead.rows.length : 0)),
1527
+ cols,
1528
+ width: Number.parseInt(existingTable.style.width, 10) || 100,
1529
+ borderWidth: Number.isNaN(Number.parseInt(existingTable.style.borderWidth, 10)) ? 1 : Number.parseInt(existingTable.style.borderWidth, 10),
1540
1530
  borderColor: existingTable.style.borderColor || '#dddddd',
1541
- cellPadding: 8, // default, hard to extract
1542
- showHeaders: !!thead,
1531
+ cellPadding: Number.isNaN(parsedPadding) ? 8 : parsedPadding,
1532
+ showHeaders: !!thead && thead.rows.length > 0,
1543
1533
  headerBgColor: thead ? getEditorStyle(thead.querySelector('th') || thead)?.backgroundColor || '#f4f4f4' : '#f4f4f4',
1544
1534
  headerTextColor: thead ? getEditorStyle(thead.querySelector('th') || thead)?.color || '#333333' : '#333333',
1545
1535
  cellBgColor: tbody ? getEditorStyle(tbody.querySelector('td') || tbody)?.backgroundColor || '#ffffff' : '#ffffff',
@@ -1547,25 +1537,21 @@ function openTableEditor(existingTable: HTMLTableElement | null = null) {
1547
1537
  alternateRows: hasAlternatingRows,
1548
1538
  alternateRowBgColor: alternateRowBg,
1549
1539
  alternateRowTextColor,
1550
- fixedLayout: existingTable.style.tableLayout === 'fixed' || true, // ברירת מחדל true
1540
+ fixedLayout: existingTable.style.tableLayout ? existingTable.style.tableLayout === 'fixed' : true,
1551
1541
  columnWidths: (() => {
1552
- const cols = tbody && tbody.rows.length > 0 ? tbody.rows[0].cells.length : 3
1553
1542
  const columnElements = Array.from(existingTable.querySelectorAll(':scope > colgroup > col')) as HTMLElement[]
1554
1543
  if (columnElements.length === cols) {
1555
1544
  return columnElements.map((column, index) =>
1556
1545
  Number.parseFloat(column.style.width)
1557
1546
  || Number.parseFloat((existingTable.rows[0]?.cells[index] as HTMLElement | undefined)?.style.width || '')
1558
- || (100 / cols))
1547
+ || Number((100 / cols).toFixed(2)))
1559
1548
  }
1560
- return Array.from({ length: cols }, (_, index) =>
1561
- Number.parseFloat((existingTable.rows[0]?.cells[index] as HTMLElement | undefined)?.style.width || '')
1562
- || (100 / cols))
1549
+ return equalColumnWidths(cols)
1563
1550
  })(),
1564
1551
  alignment: detectTableAlignment(existingTable),
1565
- direction: existingTable.dir || 'ltr'
1552
+ direction: (existingTable.dir || doc.body.dir || 'ltr') as 'ltr' | 'rtl'
1566
1553
  }
1567
1554
  } else {
1568
- // Reset form for new table
1569
1555
  tableForm.value = {
1570
1556
  rows: 3,
1571
1557
  cols: 3,
@@ -1584,14 +1570,10 @@ function openTableEditor(existingTable: HTMLTableElement | null = null) {
1584
1570
  fixedLayout: true,
1585
1571
  columnWidths: equalColumnWidths(3),
1586
1572
  alignment: 'left',
1587
- direction: 'ltr'
1573
+ direction: (doc.body.dir || 'ltr') as 'ltr' | 'rtl'
1588
1574
  }
1589
1575
  }
1590
1576
  showTableEditor.value = true
1591
- console.log('[RichText table editor] dialog state opened', {
1592
- open: showTableEditor.value,
1593
- hasPendingTable: Boolean(pendingTableData?.existingTable),
1594
- })
1595
1577
  }
1596
1578
 
1597
1579
  function openExistingTableAdvancedSettings() {
@@ -1602,67 +1584,129 @@ function openExistingTableAdvancedSettings() {
1602
1584
  showTableContextMenu.value = false
1603
1585
  }
1604
1586
 
1605
- // Function to submit table changes
1606
1587
  function submitTable() {
1607
1588
  if (!pendingTableData) { return }
1608
1589
 
1609
1590
  const { doc } = editor.state
1610
1591
  if (!doc) { return }
1611
1592
 
1593
+ const targetRows = Math.max(1, Number(tableForm.value.rows) || 1)
1594
+ const targetCols = Math.max(1, Number(tableForm.value.cols) || 1)
1595
+
1612
1596
  if (pendingTableData.existingTable) {
1613
- // Update existing table instead of creating new one
1614
1597
  const table = pendingTableData.existingTable
1615
1598
 
1616
- // Update table styles
1599
+ // 1. Reconcile header row (<thead>)
1600
+ let thead = table.querySelector('thead')
1601
+ if (tableForm.value.showHeaders) {
1602
+ if (!thead) {
1603
+ thead = doc.createElement('thead')
1604
+ const headerRow = thead.insertRow()
1605
+ for (let j = 0; j < targetCols; j++) {
1606
+ const th = doc.createElement('th')
1607
+ th.textContent = `Header ${j + 1}`
1608
+ headerRow.appendChild(th)
1609
+ }
1610
+ const colgroupEl = table.querySelector(':scope > colgroup')
1611
+ if (colgroupEl && colgroupEl.nextSibling) {
1612
+ table.insertBefore(thead, colgroupEl.nextSibling)
1613
+ } else {
1614
+ table.insertBefore(thead, table.firstChild)
1615
+ }
1616
+ } else if (thead.rows.length === 0) {
1617
+ const headerRow = thead.insertRow()
1618
+ for (let j = 0; j < targetCols; j++) {
1619
+ const th = doc.createElement('th')
1620
+ th.textContent = `Header ${j + 1}`
1621
+ headerRow.appendChild(th)
1622
+ }
1623
+ }
1624
+ } else if (thead) {
1625
+ thead.remove()
1626
+ thead = null
1627
+ }
1628
+
1629
+ // 2. Reconcile body rows (<tbody>)
1630
+ let tbody = table.querySelector('tbody')
1631
+ if (!tbody) {
1632
+ tbody = doc.createElement('tbody')
1633
+ Array.from(table.querySelectorAll(':scope > tr')).forEach(tr => tbody!.appendChild(tr))
1634
+ table.appendChild(tbody)
1635
+ }
1636
+
1637
+ while (tbody.rows.length < targetRows) {
1638
+ const newRow = tbody.insertRow()
1639
+ for (let j = 0; j < targetCols; j++) {
1640
+ const td = newRow.insertCell()
1641
+ td.innerHTML = '<br>'
1642
+ }
1643
+ }
1644
+ while (tbody.rows.length > targetRows) {
1645
+ tbody.deleteRow(tbody.rows.length - 1)
1646
+ }
1647
+
1648
+ // 3. Reconcile column count across all rows (thead + tbody)
1649
+ Array.from(table.rows).forEach((row) => {
1650
+ const isHeader = row.parentElement?.tagName === 'THEAD'
1651
+ const currentSpanCols = () => Array.from(row.cells).reduce(
1652
+ (sum, c) => sum + (Math.max(1, Number.parseInt(c.getAttribute('colspan') || '1', 10) || 1)),
1653
+ 0
1654
+ )
1655
+ while (currentSpanCols() < targetCols) {
1656
+ const cell = doc.createElement(isHeader ? 'th' : 'td')
1657
+ cell.innerHTML = isHeader ? `Header ${row.cells.length + 1}` : '<br>'
1658
+ row.appendChild(cell)
1659
+ }
1660
+ while (currentSpanCols() > targetCols && row.cells.length > 0) {
1661
+ const lastCell = row.cells[row.cells.length - 1]
1662
+ const span = Math.max(1, Number.parseInt(lastCell.getAttribute('colspan') || '1', 10) || 1)
1663
+ const excess = currentSpanCols() - targetCols
1664
+ if (span > excess) {
1665
+ lastCell.setAttribute('colspan', String(span - excess))
1666
+ } else {
1667
+ lastCell.remove()
1668
+ }
1669
+ }
1670
+ })
1671
+
1672
+ // 4. Update table-level styles, alignment, direction, and <colgroup>
1617
1673
  table.style.width = `${tableForm.value.width}%`
1618
1674
  table.style.borderCollapse = 'collapse'
1619
- table.style.marginBottom = '1rem'
1620
1675
  table.style.border = `${tableForm.value.borderWidth}px solid ${tableForm.value.borderColor}`
1676
+ table.style.tableLayout = tableForm.value.fixedLayout ? 'fixed' : 'auto'
1677
+ table.dir = tableForm.value.direction
1621
1678
 
1622
- // Set table layout
1623
- if (tableForm.value.fixedLayout) {
1624
- table.style.tableLayout = 'fixed'
1625
- } else {
1626
- table.style.tableLayout = 'auto'
1627
- }
1679
+ applyTableAlignmentToDOM(table, tableForm.value.alignment as 'left' | 'center' | 'right')
1628
1680
 
1629
1681
  let colgroup = table.querySelector(':scope > colgroup')
1630
1682
  if (!colgroup) {
1631
1683
  colgroup = doc.createElement('colgroup')
1632
1684
  table.insertBefore(colgroup, table.firstChild)
1633
1685
  }
1634
- colgroup.replaceChildren(...tableForm.value.columnWidths.slice(0, tableForm.value.cols).map((width) => {
1686
+ const widths = tableForm.value.columnWidths.length === targetCols
1687
+ ? tableForm.value.columnWidths
1688
+ : equalColumnWidths(targetCols)
1689
+ colgroup.replaceChildren(...widths.slice(0, targetCols).map((width) => {
1635
1690
  const column = doc.createElement('col')
1636
1691
  column.style.width = `${width}%`
1637
1692
  return column
1638
1693
  }))
1639
1694
 
1640
- // Don't force text alignment for all cells - let them inherit from table direction
1641
- // console.log('Setting cell text alignment:', tableForm.value.alignment)
1642
- // const allCells = table.querySelectorAll('td, th')
1643
- // allCells.forEach(cell => {
1644
- // const cellEl = cell as HTMLElement
1645
- // cellEl.style.textAlign = tableForm.value.alignment
1646
- // })
1647
-
1648
- // Set table direction
1649
- table.dir = tableForm.value.direction
1650
-
1651
- // Update border and padding for all cells
1695
+ // 5. Update cell styles
1652
1696
  const allCells = table.querySelectorAll('td, th')
1653
1697
  allCells.forEach((cell) => {
1654
- const cellEl = cell as HTMLElement
1698
+ const cellEl = cell as HTMLTableCellElement
1655
1699
  cellEl.style.padding = `${tableForm.value.cellPadding}px`
1656
1700
  cellEl.style.border = `${tableForm.value.borderWidth}px solid ${tableForm.value.borderColor}`
1657
1701
 
1658
- // Set fixed width for fixed layout
1659
1702
  if (tableForm.value.fixedLayout) {
1660
- const cellIndex = (cellEl as HTMLTableCellElement).cellIndex
1661
- cellEl.style.width = `${tableForm.value.columnWidths[cellIndex] ?? (100 / tableForm.value.cols)}%`
1703
+ const cellIndex = cellEl.cellIndex
1704
+ cellEl.style.width = `${widths[cellIndex] ?? (100 / targetCols)}%`
1705
+ } else {
1706
+ cellEl.style.removeProperty('width')
1662
1707
  }
1663
1708
  })
1664
1709
 
1665
- // Update header styles if headers exist
1666
1710
  const headers = table.querySelectorAll('th')
1667
1711
  headers.forEach((th) => {
1668
1712
  const thEl = th as HTMLElement
@@ -1670,8 +1714,6 @@ function submitTable() {
1670
1714
  thEl.style.color = tableForm.value.headerTextColor
1671
1715
  })
1672
1716
 
1673
- // Update body cell styles
1674
- const tbody = table.querySelector('tbody')
1675
1717
  if (tbody) {
1676
1718
  const rows = Array.from(tbody.querySelectorAll('tr'))
1677
1719
  rows.forEach((row, i) => {
@@ -1679,11 +1721,9 @@ function submitTable() {
1679
1721
  cells.forEach((cell) => {
1680
1722
  const cellEl = cell as HTMLElement
1681
1723
  if (tableForm.value.alternateRows && i % 2 === 1) {
1682
- // Alternate rows: use special colors
1683
1724
  cellEl.style.backgroundColor = tableForm.value.alternateRowBgColor
1684
1725
  cellEl.style.color = tableForm.value.alternateRowTextColor
1685
1726
  } else {
1686
- // Regular rows: use cell colors
1687
1727
  cellEl.style.backgroundColor = tableForm.value.cellBgColor
1688
1728
  cellEl.style.color = tableForm.value.cellTextColor
1689
1729
  }
@@ -1693,7 +1733,6 @@ function submitTable() {
1693
1733
 
1694
1734
  updateContentWithHistory(doc)
1695
1735
  } else {
1696
- // Create new table (existing logic)
1697
1736
  createNewTable(doc)
1698
1737
  }
1699
1738
 
@@ -1701,40 +1740,17 @@ function submitTable() {
1701
1740
  pendingTableData = null
1702
1741
  }
1703
1742
 
1704
- // Function to create new table (moved from submitTable)
1705
1743
  function createNewTable(doc: Document) {
1706
1744
  if (!pendingTableData) { return }
1707
1745
 
1708
- // Create new table with current settings
1709
1746
  const table = doc.createElement('table')
1710
1747
  table.style.width = `${tableForm.value.width}%`
1711
1748
  table.style.borderCollapse = 'collapse'
1712
1749
  table.style.marginBottom = '1rem'
1713
1750
  table.style.border = `${tableForm.value.borderWidth}px solid ${tableForm.value.borderColor}`
1751
+ table.style.tableLayout = tableForm.value.fixedLayout ? 'fixed' : 'auto'
1714
1752
 
1715
- // Set table layout based on fixedLayout setting
1716
- if (tableForm.value.fixedLayout) {
1717
- table.style.tableLayout = 'fixed'
1718
- } else {
1719
- table.style.tableLayout = 'auto'
1720
- }
1721
-
1722
- // Set table alignment (will be applied directly to table since no wrapper yet)
1723
- // Don't set textAlign on table - let it inherit from document direction
1724
- // table.style.textAlign = tableForm.value.alignment
1725
-
1726
- if (tableForm.value.alignment === 'center') {
1727
- table.style.marginLeft = 'auto'
1728
- table.style.marginRight = 'auto'
1729
- } else if (tableForm.value.alignment === 'right') {
1730
- table.style.marginLeft = 'auto'
1731
- table.style.marginRight = '0'
1732
- } else {
1733
- table.style.marginLeft = '0'
1734
- table.style.marginRight = 'auto'
1735
- }
1736
-
1737
- // Set table direction
1753
+ applyTableAlignmentToDOM(table, tableForm.value.alignment as 'left' | 'center' | 'right')
1738
1754
  table.dir = tableForm.value.direction
1739
1755
 
1740
1756
  const colgroup = doc.createElement('colgroup')
@@ -1745,54 +1761,39 @@ function createNewTable(doc: Document) {
1745
1761
  })
1746
1762
  table.appendChild(colgroup)
1747
1763
 
1748
- // Add header if enabled
1749
1764
  if (tableForm.value.showHeaders) {
1750
1765
  const thead = doc.createElement('thead')
1751
1766
  const headerRow = thead.insertRow()
1752
1767
  for (let j = 0; j < tableForm.value.cols; j++) {
1753
1768
  const th = doc.createElement('th')
1754
- th.innerHTML = `Header ${j + 1}`
1769
+ th.textContent = `Header ${j + 1}`
1755
1770
  th.style.padding = `${tableForm.value.cellPadding}px`
1756
1771
  th.style.border = `${tableForm.value.borderWidth}px solid ${tableForm.value.borderColor}`
1757
1772
  th.style.backgroundColor = tableForm.value.headerBgColor
1758
1773
  th.style.color = tableForm.value.headerTextColor
1759
- // Don't set textAlign - let it inherit from table direction
1760
- // th.style.textAlign = tableForm.value.alignment
1761
-
1762
- // Set fixed width for fixed layout
1763
1774
  if (tableForm.value.fixedLayout) {
1764
1775
  th.style.width = `${tableForm.value.columnWidths[j] ?? (100 / tableForm.value.cols)}%`
1765
1776
  }
1766
-
1767
1777
  headerRow.appendChild(th)
1768
1778
  }
1769
1779
  table.appendChild(thead)
1770
1780
  }
1771
1781
 
1772
- // Add body
1773
1782
  const tbody = doc.createElement('tbody')
1774
1783
  for (let i = 0; i < tableForm.value.rows; i++) {
1775
1784
  const row = tbody.insertRow()
1776
1785
  for (let j = 0; j < tableForm.value.cols; j++) {
1777
1786
  const cell = row.insertCell()
1778
- cell.innerHTML = '&nbsp;'
1787
+ cell.innerHTML = '<br>'
1779
1788
  cell.style.padding = `${tableForm.value.cellPadding}px`
1780
1789
  cell.style.border = `${tableForm.value.borderWidth}px solid ${tableForm.value.borderColor}`
1781
- // Don't set textAlign - let it inherit from table direction
1782
- // cell.style.textAlign = tableForm.value.alignment
1783
-
1784
- // Set fixed width for fixed layout
1785
1790
  if (tableForm.value.fixedLayout) {
1786
1791
  cell.style.width = `${tableForm.value.columnWidths[j] ?? (100 / tableForm.value.cols)}%`
1787
1792
  }
1788
-
1789
- // Apply background and text colors
1790
1793
  if (tableForm.value.alternateRows && i % 2 === 1) {
1791
- // Alternate rows: use special colors
1792
1794
  cell.style.backgroundColor = tableForm.value.alternateRowBgColor
1793
1795
  cell.style.color = tableForm.value.alternateRowTextColor
1794
1796
  } else {
1795
- // Regular rows: use cell colors
1796
1797
  cell.style.backgroundColor = tableForm.value.cellBgColor
1797
1798
  cell.style.color = tableForm.value.cellTextColor
1798
1799
  }
@@ -1800,9 +1801,14 @@ function createNewTable(doc: Document) {
1800
1801
  }
1801
1802
  table.appendChild(tbody)
1802
1803
 
1803
- // Insert new table
1804
1804
  const { range } = pendingTableData
1805
1805
  range.insertNode(table)
1806
+ if (!table.nextElementSibling) {
1807
+ const trailingP = doc.createElement('p')
1808
+ trailingP.dir = doc.body.dir || 'ltr'
1809
+ trailingP.innerHTML = '<br>'
1810
+ table.insertAdjacentElement('afterend', trailingP)
1811
+ }
1806
1812
  range.setStartAfter(table)
1807
1813
  range.collapse(true)
1808
1814
  const selection = doc.getSelection()
@@ -1811,72 +1817,33 @@ function createNewTable(doc: Document) {
1811
1817
  selection.addRange(range)
1812
1818
  }
1813
1819
 
1814
- updateContentWithHistory(doc)
1815
1820
  setupTableEditButtons(doc)
1821
+ updateContentWithHistory(doc)
1816
1822
  }
1817
1823
 
1818
- // Function to delete table
1819
1824
  function deleteTable() {
1820
1825
  if (!pendingTableData?.existingTable) { return }
1821
1826
 
1822
- const { existingTable } = pendingTableData
1823
- existingTable.remove()
1824
-
1825
- updateContentWithHistory(editor.state.doc!)
1827
+ removeTableElement(pendingTableData.existingTable)
1826
1828
  showTableEditor.value = false
1827
1829
  pendingTableData = null
1828
1830
  }
1829
1831
 
1830
- // Function to apply default table settings to externally created tables
1831
1832
  function applyDefaultTableSettings(table: HTMLTableElement) {
1832
- // Apply default styles from tableForm
1833
- const defaultSettings = {
1834
- fixedLayout: true,
1835
- alignment: 'start',
1836
- direction: 'ltr'
1837
- }
1838
-
1839
- // Set table layout
1840
- if (defaultSettings.fixedLayout) {
1833
+ const doc = table.ownerDocument
1834
+ if (!table.style.tableLayout) {
1841
1835
  table.style.tableLayout = 'fixed'
1842
-
1843
- // Set equal width for all columns if not already set
1844
- const headers = table.querySelectorAll('th')
1845
- const cells = table.querySelectorAll('td')
1846
-
1847
- if (headers.length > 0) {
1848
- const colWidth = `${100 / headers.length}%`
1849
- headers.forEach((th) => {
1850
- if (!(th as HTMLElement).style.width) {
1851
- (th as HTMLElement).style.width = colWidth
1852
- }
1853
- })
1854
- }
1855
-
1856
- if (cells.length > 0 && headers.length === 0) {
1857
- // If no headers, use first row to determine column count
1858
- const firstRow = table.querySelector('tr')
1859
- if (firstRow) {
1860
- const colCount = firstRow.querySelectorAll('td').length
1861
- const colWidth = `${100 / colCount}%`
1862
- cells.forEach((td) => {
1863
- if (!(td as HTMLElement).style.width) {
1864
- (td as HTMLElement).style.width = colWidth
1865
- }
1866
- })
1867
- }
1868
- }
1869
1836
  }
1870
-
1871
- // Set cell text alignment
1872
- const allCells = table.querySelectorAll('td, th')
1873
- allCells.forEach((cell) => {
1874
- const cellEl = cell as HTMLElement
1875
- cellEl.style.textAlign = defaultSettings.alignment
1876
- })
1877
-
1878
- // Set table direction
1879
- table.dir = defaultSettings.direction
1837
+ if (!table.style.width) {
1838
+ table.style.width = '100%'
1839
+ }
1840
+ if (!table.style.borderCollapse) {
1841
+ table.style.borderCollapse = 'collapse'
1842
+ }
1843
+ if (!table.dir) {
1844
+ table.dir = doc?.body?.dir || 'ltr'
1845
+ }
1846
+ syncTableColgroup(table)
1880
1847
  }
1881
1848
 
1882
1849
  // Function to delete image
@@ -1911,9 +1878,11 @@ function getCleanHTML(doc: Document | null): string {
1911
1878
  const clone = doc.body.cloneNode(true) as HTMLElement
1912
1879
 
1913
1880
  // Strip editor-UI elements that must never appear in output
1914
- clone.querySelectorAll('.table-edit-btn').forEach((btn) => { btn.remove() })
1881
+ clone.querySelectorAll('.placeholder').forEach((el) => { el.remove() })
1882
+ clone.querySelectorAll('.table-edit-btn, .table-inline-controls').forEach((btn) => { btn.remove() })
1915
1883
  clone.querySelectorAll('.media-selected').forEach((el) => { el.classList.remove('media-selected') })
1916
1884
  clone.querySelectorAll('.table-cell-selected').forEach((el) => { el.classList.remove('table-cell-selected') })
1885
+ clone.querySelectorAll('[data-dir-explicit]').forEach((el) => { el.removeAttribute('data-dir-explicit') })
1917
1886
 
1918
1887
  // Unwrap .table-wrapper divs: restore original table margin-based alignment
1919
1888
  clone.querySelectorAll('.table-wrapper').forEach((wrapper) => {
@@ -2032,7 +2001,12 @@ function getCleanHTML(doc: Document | null): string {
2032
2001
  if (fig.classList.length === 0) { fig.removeAttribute('class') }
2033
2002
  })
2034
2003
 
2035
- return clone.innerHTML
2004
+ const html = clone.innerHTML
2005
+ if (!clone.textContent?.trim() && !clone.querySelector('img, video, iframe, table, hr, figure')) {
2006
+ return ''
2007
+ }
2008
+
2009
+ return html
2036
2010
  }
2037
2011
 
2038
2012
  // Expose openLinkModal to editor state
@@ -2045,336 +2019,135 @@ function getCleanHTML(doc: Document | null): string {
2045
2019
  ; (editor.state as any).openEmbedModal = openEmbedModal
2046
2020
  ; (editor.state as any).openVideoModal = openVideoModal
2047
2021
 
2048
- // Table manipulation functions
2022
+ // Table manipulation functions (delegating to unified 2D grid table utilities)
2049
2023
  function mergeCellRight() {
2050
2024
  const { doc } = editor.state
2051
2025
  if (!doc || !contextMenuCell.value) { return }
2052
-
2053
- const cell = contextMenuCell.value
2054
- const row = cell.parentElement as HTMLTableRowElement
2055
- const cellIndex = Array.from(row.cells).indexOf(cell)
2056
- const table = cell.closest('table') as HTMLTableElement
2057
- const isRTL = table?.dir === 'rtl'
2058
-
2059
- // In RTL, "right" means the previous cell, in LTR it means the next cell
2060
- const targetIndex = isRTL ? cellIndex - 1 : cellIndex + 1
2061
- const targetCell = row.cells[targetIndex]
2062
-
2063
- if (targetCell && targetIndex >= 0 && targetIndex < row.cells.length) {
2064
- // Combine content
2065
- if (targetCell.innerHTML.trim()) {
2066
- cell.innerHTML += ` ${targetCell.innerHTML}`
2067
- }
2068
-
2069
- // Update colspan
2070
- const currentColspan = Number.parseInt(cell.getAttribute('colspan') || '1')
2071
- const targetColspan = Number.parseInt(targetCell.getAttribute('colspan') || '1')
2072
- cell.setAttribute('colspan', (currentColspan + targetColspan).toString())
2073
-
2074
- // Remove the target cell
2075
- targetCell.remove()
2076
-
2077
- updateContentWithHistory(doc)
2078
- }
2026
+ tableMergeCells(editor.state.range, doc, contextMenuCell.value, 'right')
2079
2027
  }
2080
2028
 
2081
2029
  function mergeCellDown() {
2082
2030
  const { doc } = editor.state
2083
2031
  if (!doc || !contextMenuCell.value) { return }
2084
-
2085
- const cell = contextMenuCell.value
2086
- const table = cell.closest('table') as HTMLTableElement
2087
- const row = cell.parentElement as HTMLTableRowElement
2088
- const cellIndex = Array.from(row.cells).indexOf(cell)
2089
-
2090
- // Find the row below
2091
- const tbody = table.querySelector('tbody') || table
2092
- const rows = Array.from(tbody.querySelectorAll('tr'))
2093
- const currentRowIndex = rows.indexOf(row)
2094
- const nextRow = rows[currentRowIndex + 1]
2095
-
2096
- if (nextRow) {
2097
- const targetCell = nextRow.cells[cellIndex]
2098
- if (targetCell) {
2099
- // Combine content
2100
- if (targetCell.innerHTML.trim()) {
2101
- cell.innerHTML += `<br>${targetCell.innerHTML}`
2102
- }
2103
-
2104
- // Update rowspan
2105
- const currentRowspan = Number.parseInt(cell.getAttribute('rowspan') || '1')
2106
- const targetRowspan = Number.parseInt(targetCell.getAttribute('rowspan') || '1')
2107
- cell.setAttribute('rowspan', (currentRowspan + targetRowspan).toString())
2108
-
2109
- // Remove the target cell
2110
- targetCell.remove()
2111
-
2112
- updateContentWithHistory(doc)
2113
- }
2114
- }
2032
+ tableMergeCells(editor.state.range, doc, contextMenuCell.value, 'down')
2115
2033
  }
2116
2034
 
2117
2035
  function splitCell() {
2118
2036
  const { doc } = editor.state
2119
2037
  if (!doc || !contextMenuCell.value) { return }
2120
-
2121
- const cell = contextMenuCell.value
2122
- const table = cell.closest('table') as HTMLTableElement
2123
- const isRTL = table?.dir === 'rtl'
2124
- const colspan = Number.parseInt(cell.getAttribute('colspan') || '1')
2125
- const rowspan = Number.parseInt(cell.getAttribute('rowspan') || '1')
2126
-
2127
- if (colspan > 1) {
2128
- // Split horizontally
2129
- cell.setAttribute('colspan', '1')
2130
- const row = cell.parentElement as HTMLTableRowElement
2131
-
2132
- // Add new cells to the right (or left in RTL)
2133
- for (let i = 1; i < colspan; i++) {
2134
- const newCell = doc.createElement(cell.tagName.toLowerCase() as 'td' | 'th')
2135
- newCell.innerHTML = '&nbsp;'
2136
- newCell.style.cssText = cell.style.cssText
2137
-
2138
- if (isRTL) {
2139
- row.insertBefore(newCell, cell)
2140
- } else {
2141
- if (cell.nextSibling) {
2142
- row.insertBefore(newCell, cell.nextSibling)
2143
- } else {
2144
- row.appendChild(newCell)
2145
- }
2146
- }
2147
- }
2148
- } else if (rowspan > 1) {
2149
- // Split vertically
2150
- cell.setAttribute('rowspan', '1')
2151
- const row = cell.parentElement as HTMLTableRowElement
2152
- const cellIndex = Array.from(row.cells).indexOf(cell)
2153
- const tbody = table.querySelector('tbody') || table
2154
- const rows = Array.from(tbody.querySelectorAll('tr'))
2155
- const currentRowIndex = rows.indexOf(row)
2156
-
2157
- // Add new cells to rows below
2158
- for (let i = 1; i < rowspan; i++) {
2159
- const targetRow = rows[currentRowIndex + i]
2160
- if (targetRow) {
2161
- const newCell = doc.createElement(cell.tagName.toLowerCase() as 'td' | 'th')
2162
- newCell.innerHTML = '&nbsp;'
2163
- newCell.style.cssText = cell.style.cssText
2164
-
2165
- // Insert at the correct position
2166
- if (cellIndex < targetRow.cells.length) {
2167
- targetRow.insertBefore(newCell, targetRow.cells[cellIndex])
2168
- } else {
2169
- targetRow.appendChild(newCell)
2170
- }
2171
- }
2172
- }
2173
- }
2174
-
2175
- updateContentWithHistory(doc)
2038
+ tableSplitCell(editor.state.range, doc, contextMenuCell.value)
2176
2039
  }
2177
2040
 
2178
2041
  function insertRowAbove() {
2179
2042
  const { doc } = editor.state
2180
2043
  if (!doc || !contextMenuCell.value) { return }
2181
-
2182
- const cell = contextMenuCell.value
2183
- const row = cell.parentElement as HTMLTableRowElement
2184
-
2185
- // Create new row with same number of columns
2186
- const newRow = doc.createElement('tr')
2187
- const columnCount = Array.from(row.cells).reduce((total, cell) => {
2188
- return total + Number.parseInt(cell.getAttribute('colspan') || '1')
2189
- }, 0)
2190
-
2191
- for (let i = 0; i < columnCount; i++) {
2192
- const newCell = doc.createElement('td')
2193
- newCell.innerHTML = '&nbsp;'
2194
- newCell.style.padding = '8px'
2195
- newCell.style.border = '1px solid #ddd'
2196
- newRow.appendChild(newCell)
2197
- }
2198
-
2199
- // Insert before current row
2200
- row.insertAdjacentElement('beforebegin', newRow)
2201
-
2202
- updateContentWithHistory(doc)
2044
+ tableAddRow('before', editor.state.range, doc, contextMenuCell.value)
2203
2045
  }
2204
2046
 
2205
2047
  function insertRowBelow() {
2206
2048
  const { doc } = editor.state
2207
2049
  if (!doc || !contextMenuCell.value) { return }
2208
-
2209
- const cell = contextMenuCell.value
2210
- const row = cell.parentElement as HTMLTableRowElement
2211
-
2212
- // Create new row with same number of columns
2213
- const newRow = doc.createElement('tr')
2214
- const columnCount = Array.from(row.cells).reduce((total, cell) => {
2215
- return total + Number.parseInt(cell.getAttribute('colspan') || '1')
2216
- }, 0)
2217
-
2218
- for (let i = 0; i < columnCount; i++) {
2219
- const newCell = doc.createElement('td')
2220
- newCell.innerHTML = '&nbsp;'
2221
- newCell.style.padding = '8px'
2222
- newCell.style.border = '1px solid #ddd'
2223
- newRow.appendChild(newCell)
2224
- }
2225
-
2226
- // Insert after current row
2227
- row.insertAdjacentElement('afterend', newRow)
2228
-
2229
- updateContentWithHistory(doc)
2050
+ tableAddRow('after', editor.state.range, doc, contextMenuCell.value)
2230
2051
  }
2231
2052
 
2232
2053
  function deleteRow() {
2233
2054
  const { doc } = editor.state
2234
2055
  if (!doc || !contextMenuCell.value) { return }
2235
-
2236
- const cell = contextMenuCell.value
2237
- const row = cell.parentElement as HTMLTableRowElement
2238
- const table = cell.closest('table') as HTMLTableElement
2239
-
2240
- // Don't delete if it's the only row
2241
- const tbody = table.querySelector('tbody') || table
2242
- const rows = tbody.querySelectorAll('tr')
2243
-
2244
- if (rows.length <= 1) { return }
2245
-
2246
- row.remove()
2247
- updateContentWithHistory(doc)
2056
+ tableDeleteRow(editor.state.range, contextMenuCell.value)
2248
2057
  }
2249
2058
 
2250
2059
  function insertColumnLeft() {
2251
2060
  const { doc } = editor.state
2252
2061
  if (!doc || !contextMenuCell.value) { return }
2253
-
2254
- const cell = contextMenuCell.value
2255
- const table = cell.closest('table') as HTMLTableElement
2256
- const cellIndex = Array.from((cell.parentElement as HTMLTableRowElement).cells).indexOf(cell)
2257
-
2258
- // Add new cell to each row at the same index
2259
- const rows = table.querySelectorAll('tr')
2260
- rows.forEach((row) => {
2261
- const newCell = doc.createElement(row.parentElement?.tagName === 'THEAD' ? 'th' : 'td')
2262
- newCell.innerHTML = '&nbsp;'
2263
- newCell.style.padding = '8px'
2264
- newCell.style.border = '1px solid #ddd'
2265
-
2266
- if (cellIndex < row.cells.length) {
2267
- row.insertBefore(newCell, row.cells[cellIndex])
2268
- } else {
2269
- row.appendChild(newCell)
2270
- }
2271
- })
2272
-
2273
- updateContentWithHistory(doc)
2062
+ tableInsertColumn('before', editor.state.range, contextMenuCell.value)
2274
2063
  }
2275
2064
 
2276
2065
  function insertColumnRight() {
2277
2066
  const { doc } = editor.state
2278
2067
  if (!doc || !contextMenuCell.value) { return }
2279
-
2280
- const cell = contextMenuCell.value
2281
- const table = cell.closest('table') as HTMLTableElement
2282
- const cellIndex = Array.from((cell.parentElement as HTMLTableRowElement).cells).indexOf(cell)
2283
-
2284
- // Add new cell to each row after the current index
2285
- const rows = table.querySelectorAll('tr')
2286
- rows.forEach((row) => {
2287
- const newCell = doc.createElement(row.parentElement?.tagName === 'THEAD' ? 'th' : 'td')
2288
- newCell.innerHTML = '&nbsp;'
2289
- newCell.style.padding = '8px'
2290
- newCell.style.border = '1px solid #ddd'
2291
-
2292
- const targetIndex = cellIndex + 1
2293
- if (targetIndex < row.cells.length) {
2294
- row.insertBefore(newCell, row.cells[targetIndex])
2295
- } else {
2296
- row.appendChild(newCell)
2297
- }
2298
- })
2299
-
2300
- updateContentWithHistory(doc)
2068
+ tableInsertColumn('after', editor.state.range, contextMenuCell.value)
2301
2069
  }
2302
2070
 
2303
2071
  function deleteColumn() {
2304
2072
  const { doc } = editor.state
2305
2073
  if (!doc || !contextMenuCell.value) { return }
2306
-
2307
- const cell = contextMenuCell.value
2308
- const table = cell.closest('table') as HTMLTableElement
2309
- const cellIndex = Array.from((cell.parentElement as HTMLTableRowElement).cells).indexOf(cell)
2310
-
2311
- // Check if this is the only column
2312
- const firstRow = table.querySelector('tr')
2313
- if (firstRow && firstRow.cells.length <= 1) { return }
2314
-
2315
- // Remove cell from each row at the same index
2316
- const rows = table.querySelectorAll('tr')
2317
- rows.forEach((row) => {
2318
- if (row.cells[cellIndex]) {
2319
- row.cells[cellIndex].remove()
2320
- }
2321
- })
2322
-
2323
- updateContentWithHistory(doc)
2074
+ tableDeleteColumn(editor.state.range, contextMenuCell.value)
2324
2075
  }
2325
2076
 
2326
- // Table context menu functions
2327
2077
  const canMergeRight = computed(() => {
2328
2078
  if (!contextMenuCell.value) { return false }
2329
2079
  const cell = contextMenuCell.value
2330
- const row = cell.parentElement as HTMLTableRowElement
2331
- const cellIndex = Array.from(row.cells).indexOf(cell)
2332
- const table = cell.closest('table') as HTMLTableElement
2333
- const isRTL = table?.dir === 'rtl'
2334
- const targetIndex = isRTL ? cellIndex - 1 : cellIndex + 1
2335
- return targetIndex >= 0 && targetIndex < row.cells.length
2080
+ const table = cell.closest('table') as HTMLTableElement | null
2081
+ if (!table) { return false }
2082
+ const map = buildTableGrid(table)
2083
+ const coords = findCellCoords(map, cell)
2084
+ if (!coords) { return false }
2085
+ const isRTL = table.dir === 'rtl'
2086
+ const targetCol = isRTL ? coords.col - 1 : coords.col + coords.colspan
2087
+ const neighbor = map.grid[coords.row]?.[targetCol]
2088
+ return Boolean(neighbor && neighbor.cell !== cell && neighbor.cell.parentElement === cell.parentElement)
2336
2089
  })
2337
2090
 
2338
2091
  const canMergeDown = computed(() => {
2339
2092
  if (!contextMenuCell.value) { return false }
2340
2093
  const cell = contextMenuCell.value
2341
- const table = cell.closest('table') as HTMLTableElement
2342
- const rows = Array.from(table.rows)
2343
- const currentRow = cell.parentElement as HTMLTableRowElement
2344
- const currentRowIndex = rows.indexOf(currentRow)
2345
- return currentRowIndex < rows.length - 1
2094
+ const table = cell.closest('table') as HTMLTableElement | null
2095
+ if (!table) { return false }
2096
+ const map = buildTableGrid(table)
2097
+ const coords = findCellCoords(map, cell)
2098
+ if (!coords) { return false }
2099
+ const targetRow = coords.row + coords.rowspan
2100
+ const neighbor = map.grid[targetRow]?.[coords.col]
2101
+ return Boolean(
2102
+ neighbor
2103
+ && neighbor.cell !== cell
2104
+ && neighbor.cell.parentElement?.parentElement === cell.parentElement?.parentElement
2105
+ )
2346
2106
  })
2347
2107
 
2348
2108
  const canSplit = computed(() => {
2349
2109
  if (!contextMenuCell.value) { return false }
2350
2110
  const cell = contextMenuCell.value
2351
- const colspan = Number.parseInt(cell.getAttribute('colspan') || '1')
2352
- const rowspan = Number.parseInt(cell.getAttribute('rowspan') || '1')
2111
+ const colspan = Number.parseInt(cell.getAttribute('colspan') || '1', 10)
2112
+ const rowspan = Number.parseInt(cell.getAttribute('rowspan') || '1', 10)
2353
2113
  return colspan > 1 || rowspan > 1
2354
2114
  })
2355
2115
 
2356
- function handleTableContextMenu(event: MouseEvent) {
2357
- const target = event.target as HTMLElement
2358
- const cell = target.closest('td, th') as HTMLTableCellElement
2359
-
2360
- if (cell && cell.closest('table')) {
2361
- event.preventDefault()
2362
- contextMenuCell.value = cell
2363
- contextMenuPosition.value = { x: event.clientX, y: event.clientY }
2364
- showTableContextMenu.value = true
2116
+ const tableContextMenuItems = computed<DropdownItem[]>(() => {
2117
+ const items: DropdownItem[] = [
2118
+ {
2119
+ label: 'Advanced Settings',
2120
+ icon: 'settings',
2121
+ action: openExistingTableAdvancedSettings,
2122
+ },
2123
+ { divider: true },
2124
+ ]
2125
+
2126
+ if (canMergeRight.value) {
2127
+ items.push({ label: 'Merge Right', icon: 'start', action: mergeCellRight })
2128
+ }
2129
+ if (canMergeDown.value) {
2130
+ items.push({ label: 'Merge Down', icon: 'text_select_move_down', action: mergeCellDown })
2131
+ }
2132
+ if (canSplit.value) {
2133
+ items.push({ label: 'Split Cell', icon: 'call_split', action: splitCell })
2134
+ }
2135
+ if (canMergeRight.value || canMergeDown.value || canSplit.value) {
2136
+ items.push({ divider: true })
2365
2137
  }
2366
- }
2367
2138
 
2368
- // Handle clicks outside context menu
2369
- // function handleDocumentClick(event: MouseEvent) {
2370
- // if (showTableContextMenu.value) {
2371
- // const target = event.target as HTMLElement
2372
- // const contextMenu = target.closest('.table-context-menu')
2373
- // if (!contextMenu) {
2374
- // closeTableContextMenu()
2375
- // }
2376
- // }
2377
- // }
2139
+ items.push(
2140
+ { label: 'Insert Row Above', icon: 'add_row_above', action: insertRowAbove },
2141
+ { label: 'Insert Row Below', icon: 'add_row_below', action: insertRowBelow },
2142
+ { label: 'Delete Row', icon: 'remove', danger: true, action: deleteRow },
2143
+ { divider: true },
2144
+ { label: 'Insert Column Left', icon: 'add_column_left', action: insertColumnLeft },
2145
+ { label: 'Insert Column Right', icon: 'add_column_right', action: insertColumnRight },
2146
+ { label: 'Delete Column', icon: 'delete', danger: true, action: deleteColumn },
2147
+ )
2148
+
2149
+ return items
2150
+ })
2378
2151
 
2379
2152
  // Expose debug methods if debug mode is enabled
2380
2153
  const debugMethods = computed(() => editor.state.debug)
@@ -2398,87 +2171,473 @@ const editorHeight = computed(() => {
2398
2171
  return '240px' // default height
2399
2172
  })
2400
2173
 
2174
+ let bodyMutationObserver: MutationObserver | null = null
2175
+ let activeResizeCleanup: (() => void) | null = null
2176
+ const tablePointerSession = usePointerSession()
2177
+
2401
2178
  // Cleanup on component unmount
2402
2179
  onUnmounted(() => {
2180
+ bodyMutationObserver?.disconnect()
2181
+ bodyMutationObserver = null
2182
+ tablePointerSession.cancel()
2183
+ activeResizeCleanup?.()
2184
+ activeResizeCleanup = null
2403
2185
  editor.cleanup()
2404
2186
  })
2405
2187
 
2406
- function setupTableEditButtons(doc: Document) {
2407
- const previousEditHandler = (doc as any).__tableEditPointerHandler as ((event: PointerEvent) => void) | undefined
2408
- if (previousEditHandler) {
2409
- doc.removeEventListener('pointerdown', previousEditHandler, true)
2188
+ function setupTableColumnResizing(doc: Document) {
2189
+ if (activeResizeCleanup) {
2190
+ activeResizeCleanup()
2191
+ activeResizeCleanup = null
2410
2192
  }
2411
2193
 
2412
- const tableEditPointerHandler = (event: PointerEvent) => {
2413
- const target = event.target as Element | null
2414
- const editButton = target?.closest('.table-edit-btn')
2415
- if (!editButton) { return }
2194
+ const EDGE_THRESHOLD = 5
2195
+ const ADD_TRIGGER_ZONE = 28
2196
+ const DRAG_THRESHOLD = 5
2197
+ let isSessionActive = false
2416
2198
 
2417
- const table = editButton.closest('.table-wrapper')?.querySelector('table') as HTMLTableElement | null
2418
- console.log('[RichText table editor] delegated pointerdown received', {
2419
- pointerType: event.pointerType,
2420
- hasTable: Boolean(table),
2199
+ let activeHoverState: {
2200
+ table: HTMLTableElement
2201
+ cell: HTMLTableCellElement
2202
+ insertPosition: 'before' | 'after'
2203
+ } | null = null
2204
+
2205
+ const hideAllInlineControls = () => {
2206
+ doc.querySelectorAll<HTMLElement>('.table-inline-controls').forEach((ctrl) => {
2207
+ ctrl.classList.remove('is-visible', 'show-col-add', 'show-col-thumb', 'is-dragging-col')
2421
2208
  })
2422
- if (!table) { return }
2209
+ }
2423
2210
 
2424
- event.preventDefault()
2425
- event.stopPropagation()
2426
- event.stopImmediatePropagation()
2427
- globalThis.setTimeout(() => {
2428
- try {
2429
- openTableEditor(table)
2430
- } catch (error) {
2431
- console.error('[RichText table editor] delegated open failed', error)
2432
- }
2433
- }, 0)
2211
+ const highlightTableColumn = (table: HTMLTableElement, colIndex: number) => {
2212
+ const map = buildTableGrid(table)
2213
+ doc.querySelectorAll('.table-cell-selected').forEach(c => c.classList.remove('table-cell-selected'))
2214
+ for (let r = 0; r < map.rowCount; r++) {
2215
+ map.grid[r]?.[colIndex]?.cell.classList.add('table-cell-selected')
2216
+ }
2434
2217
  }
2435
2218
 
2436
- ;(doc as any).__tableEditPointerHandler = tableEditPointerHandler
2437
- doc.addEventListener('pointerdown', tableEditPointerHandler, true)
2219
+ const applyColumnWidthsToDOM = (table: HTMLTableElement, widths: number[]) => {
2220
+ let colgroup = table.querySelector(':scope > colgroup')
2221
+ if (!colgroup) {
2222
+ colgroup = doc.createElement('colgroup')
2223
+ table.insertBefore(colgroup, table.firstChild)
2224
+ }
2225
+ colgroup.replaceChildren(...widths.map((w) => {
2226
+ const col = doc.createElement('col')
2227
+ col.style.width = `${w}%`
2228
+ return col
2229
+ }))
2438
2230
 
2439
- // Simple function to add edit buttons to all tables
2440
- function addEditButtonsToTables() {
2441
- const tables = doc.querySelectorAll<HTMLTableElement>('table:not([data-edit-button-added])')
2231
+ table.style.tableLayout = 'fixed'
2232
+ Array.from(table.rows).forEach((row) => {
2233
+ Array.from(row.cells).forEach((c, idx) => {
2234
+ if (widths[idx] !== undefined && (c.colSpan || 1) === 1) {
2235
+ c.style.width = `${widths[idx]}%`
2236
+ }
2237
+ })
2238
+ })
2239
+ }
2442
2240
 
2443
- tables.forEach((table) => {
2444
- // Create edit button as a span element instead of button
2445
- const editBtn = doc.createElement('span')
2446
- editBtn.className = 'table-edit-btn'
2447
- editBtn.textContent = '✎'
2448
- editBtn.title = 'Edit Table'
2449
- editBtn.setAttribute('aria-label', 'Edit Table')
2241
+ const getHoveredResizeCell = (e: PointerEvent): { cell: HTMLTableCellElement, table: HTMLTableElement } | null => {
2242
+ const target = e.target as HTMLElement | null
2243
+ if (target?.closest('.table-inline-controls, .table-edit-btn')) { return null }
2244
+ const cell = target?.closest('td, th') as HTMLTableCellElement | null
2245
+ if (!cell) { return null }
2246
+ const table = cell.closest('table') as HTMLTableElement | null
2247
+ if (!table) { return null }
2248
+ const rect = cell.getBoundingClientRect()
2249
+ const isRTL = table.dir === 'rtl'
2250
+ const nearTrailingEdge = isRTL
2251
+ ? (e.clientX - rect.left <= EDGE_THRESHOLD)
2252
+ : (rect.right - e.clientX <= EDGE_THRESHOLD)
2253
+ if (!nearTrailingEdge) { return null }
2254
+ return { cell, table }
2255
+ }
2450
2256
 
2451
- // Prevent any content insertion into button
2452
- editBtn.setAttribute('contenteditable', 'false')
2453
- editBtn.setAttribute('tabindex', '-1')
2454
- editBtn.style.pointerEvents = 'auto'
2455
- editBtn.style.userSelect = 'none'
2257
+ const updateInlineColumnControls = (e: PointerEvent) => {
2258
+ const target = e.target as HTMLElement | null
2259
+ if (!target) {
2260
+ hideAllInlineControls()
2261
+ return
2262
+ }
2456
2263
 
2457
- editBtn.addEventListener('click', (e) => {
2458
- console.log('[RichText table editor] edit icon click received')
2459
- e.preventDefault()
2460
- e.stopPropagation()
2461
- e.stopImmediatePropagation()
2462
- })
2264
+ // Keep current handle position locked while pointer is over the inline control buttons themselves
2265
+ if (target.closest('.table-inline-controls')) {
2266
+ return
2267
+ }
2463
2268
 
2464
- // Prevent focus and content insertion
2465
- editBtn.addEventListener('focus', (e) => {
2466
- e.preventDefault()
2467
- e.stopPropagation()
2468
- editBtn.blur()
2469
- })
2269
+ const cell = target.closest('td, th') as HTMLTableCellElement | null
2270
+ const table = cell?.closest('table') as HTMLTableElement | null
2271
+ const wrapper = table?.closest('.table-wrapper') as HTMLElement | null
2272
+ if (!cell || !table || !wrapper) {
2273
+ hideAllInlineControls()
2274
+ return
2275
+ }
2470
2276
 
2471
- editBtn.addEventListener('pointerdown', (e) => {
2472
- console.log('[RichText table editor] edit icon pointerdown received', {
2473
- pointerType: e.pointerType,
2474
- tableConnected: table.isConnected,
2475
- })
2476
- e.preventDefault()
2477
- e.stopPropagation()
2478
- e.stopImmediatePropagation()
2479
- })
2277
+ const controls = wrapper.querySelector<HTMLElement>('.table-inline-controls')
2278
+ const addBtn = controls?.querySelector<HTMLElement>('.table-col-add-btn')
2279
+ const addLine = controls?.querySelector<HTMLElement>('.table-col-add-line')
2280
+ const thumbBtn = controls?.querySelector<HTMLElement>('.table-col-thumb')
2281
+ if (!controls || !addBtn || !addLine || !thumbBtn) { return }
2282
+
2283
+ doc.querySelectorAll<HTMLElement>('.table-inline-controls').forEach((other) => {
2284
+ if (other !== controls) {
2285
+ other.classList.remove('is-visible', 'show-col-add', 'show-col-thumb', 'is-dragging-col')
2286
+ }
2287
+ })
2288
+
2289
+ const wrapperRect = wrapper.getBoundingClientRect()
2290
+ const tableRect = table.getBoundingClientRect()
2291
+ const cellRect = cell.getBoundingClientRect()
2292
+
2293
+ const tableTop = tableRect.top - wrapperRect.top
2294
+ const tableHeight = tableRect.height
2295
+ const cellLeft = cellRect.left - wrapperRect.left
2296
+ const cellRight = cellRect.right - wrapperRect.left
2297
+ const cellCenter = cellLeft + cellRect.width / 2
2298
+
2299
+ const isRTL = table.dir === 'rtl'
2300
+
2301
+ const distLeft = Math.abs(e.clientX - cellRect.left)
2302
+ const distRight = Math.abs(e.clientX - cellRect.right)
2303
+ const nearLeft = distLeft <= distRight
2304
+ const minEdgeDist = Math.min(distLeft, distRight)
2305
+ const nearTopBand = (e.clientY - tableRect.top) <= 36
2306
+
2307
+ // In LTR: left edge inserts 'before', right edge inserts 'after'. In RTL: reversed.
2308
+ const insertPosition: 'before' | 'after' = nearLeft
2309
+ ? (isRTL ? 'after' : 'before')
2310
+ : (isRTL ? 'before' : 'after')
2311
+ const boundaryX = nearLeft ? cellLeft : cellRight
2312
+
2313
+ activeHoverState = {
2314
+ table,
2315
+ cell,
2316
+ insertPosition,
2317
+ }
2318
+
2319
+ controls.classList.add('is-visible', 'show-col-thumb')
2320
+ thumbBtn.style.left = `${cellCenter}px`
2321
+ thumbBtn.style.top = `${tableTop}px`
2322
+
2323
+ // Show '+' between columns when near a column divider or near the top header band
2324
+ if (minEdgeDist <= ADD_TRIGGER_ZONE || nearTopBand) {
2325
+ addBtn.style.left = `${boundaryX}px`
2326
+ addBtn.style.top = `${tableTop}px`
2327
+ addLine.style.left = `${boundaryX}px`
2328
+ addLine.style.top = `${tableTop}px`
2329
+ addLine.style.height = `${tableHeight}px`
2330
+ controls.classList.add('show-col-add')
2331
+ } else {
2332
+ controls.classList.remove('show-col-add')
2333
+ }
2334
+ }
2335
+
2336
+ const onHoverPointerMove = (e: PointerEvent) => {
2337
+ if (isSessionActive) { return }
2338
+ updateInlineColumnControls(e)
2339
+
2340
+ const hovered = getHoveredResizeCell(e)
2341
+ if (hovered && hovered.cell.nextElementSibling) {
2342
+ doc.body.style.cursor = 'col-resize'
2343
+ } else if (doc.body.style.cursor === 'col-resize') {
2344
+ doc.body.style.cursor = ''
2345
+ }
2346
+ }
2347
+
2348
+ const onPointerDown = (e: PointerEvent) => {
2349
+ const target = e.target as HTMLElement | null
2350
+
2351
+ // 1. Handle native inline '+' (Add Column) click
2352
+ const addColBtn = target?.closest('.table-col-add-btn') as HTMLElement | null
2353
+ if (addColBtn) {
2354
+ e.preventDefault()
2355
+ e.stopPropagation()
2356
+ e.stopImmediatePropagation()
2357
+ const wrapper = addColBtn.closest('.table-wrapper')
2358
+ const table = wrapper?.querySelector('table') as HTMLTableElement | null
2359
+ const fallbackCell = table?.querySelector('th, td') as HTMLTableCellElement | null
2360
+ const targetCell = (activeHoverState?.table === table && activeHoverState?.cell.isConnected)
2361
+ ? activeHoverState.cell
2362
+ : (contextMenuCell.value?.closest('table') === table ? contextMenuCell.value : fallbackCell)
2363
+ const pos = activeHoverState?.table === table
2364
+ ? activeHoverState.insertPosition
2365
+ : ((addColBtn.getAttribute('data-insert-pos') as 'before' | 'after') || 'after')
2366
+
2367
+ if (targetCell) {
2368
+ tableInsertColumn(pos, editor.state.range, targetCell)
2369
+ updateContentWithHistory(doc)
2370
+ }
2371
+ return
2372
+ }
2373
+
2374
+ // 2. Handle native inline '⋯' (Column Thumb: drag via usePointerSession to reorder, or click to open ContextMenu)
2375
+ const thumbColBtn = target?.closest('.table-col-thumb') as HTMLElement | null
2376
+ if (thumbColBtn) {
2377
+ e.stopPropagation()
2378
+ e.stopImmediatePropagation()
2379
+ const wrapper = thumbColBtn.closest('.table-wrapper') as HTMLElement | null
2380
+ const table = wrapper?.querySelector('table') as HTMLTableElement | null
2381
+ const controls = wrapper?.querySelector('.table-inline-controls') as HTMLElement | null
2382
+ const addLine = wrapper?.querySelector('.table-col-add-line') as HTMLElement | null
2383
+ const fallbackCell = table?.querySelector('th, td') as HTMLTableCellElement | null
2384
+ const targetCell = (activeHoverState?.table === table && activeHoverState?.cell.isConnected)
2385
+ ? activeHoverState.cell
2386
+ : (contextMenuCell.value?.closest('table') === table ? contextMenuCell.value : fallbackCell)
2387
+
2388
+ if (table && wrapper && controls && addLine && targetCell) {
2389
+ const map = buildTableGrid(table)
2390
+ const coords = findCellCoords(map, targetCell)
2391
+ const fromCol = coords?.col ?? 0
2392
+ let toCol = fromCol
2393
+ const initialThumbLeft = thumbColBtn.style.left
2394
+
2395
+ isSessionActive = true
2396
+ tablePointerSession.start(e, {
2397
+ target: thumbColBtn,
2398
+ threshold: DRAG_THRESHOLD,
2399
+ cursor: 'grabbing',
2400
+ onThreshold: () => {
2401
+ controls.classList.add('is-dragging-col')
2402
+ controls.classList.remove('show-col-add')
2403
+ highlightTableColumn(table, fromCol)
2404
+ },
2405
+ onMove: (moveEvent, { exceededThreshold }) => {
2406
+ if (!exceededThreshold) { return }
2407
+ const wrapperRect = wrapper.getBoundingClientRect()
2408
+ const tableRect = table.getBoundingClientRect()
2409
+ const clampedX = Math.max(tableRect.left, Math.min(tableRect.right, moveEvent.clientX))
2410
+ thumbColBtn.style.left = `${clampedX - wrapperRect.left}px`
2411
+
2412
+ const firstRow = table.rows[0]
2413
+ if (firstRow) {
2414
+ let targetIdx = fromCol
2415
+ let indicatorX = clampedX - wrapperRect.left
2416
+ const cells = Array.from(firstRow.cells)
2417
+ for (let i = 0; i < cells.length; i++) {
2418
+ const r = cells[i].getBoundingClientRect()
2419
+ if (clampedX >= r.left && clampedX <= r.right) {
2420
+ targetIdx = i
2421
+ const isRTL = table.dir === 'rtl'
2422
+ const dropEdge = i > fromCol
2423
+ ? (isRTL ? r.left : r.right)
2424
+ : (isRTL ? r.right : r.left)
2425
+ indicatorX = dropEdge - wrapperRect.left
2426
+ break
2427
+ }
2428
+ }
2429
+ toCol = targetIdx
2430
+ addLine.style.left = `${indicatorX}px`
2431
+ addLine.style.top = `${tableRect.top - wrapperRect.top}px`
2432
+ addLine.style.height = `${tableRect.height}px`
2433
+ }
2434
+ },
2435
+ onEnd: (_endEvent, { exceededThreshold }) => {
2436
+ isSessionActive = false
2437
+ controls.classList.remove('is-dragging-col')
2438
+
2439
+ if (exceededThreshold) {
2440
+ if (fromCol !== toCol) {
2441
+ tableMoveColumn(table, fromCol, toCol)
2442
+ highlightTableColumn(table, toCol)
2443
+ updateContentWithHistory(doc)
2444
+ } else {
2445
+ thumbColBtn.style.left = initialThumbLeft
2446
+ }
2447
+ } else {
2448
+ // Clicked without dragging: highlight column and open ContextMenu below the thumb
2449
+ contextMenuCell.value = targetCell
2450
+ highlightTableColumn(table, fromCol)
2451
+ const iframeRect = iframe.value?.getBoundingClientRect()
2452
+ const thumbRect = thumbColBtn.getBoundingClientRect()
2453
+ if (iframeRect) {
2454
+ contextMenuPosition.value = {
2455
+ x: iframeRect.left + thumbRect.left,
2456
+ y: iframeRect.top + thumbRect.bottom + 6,
2457
+ }
2458
+ showTableContextMenu.value = true
2459
+ }
2460
+ }
2461
+ },
2462
+ onCancel: () => {
2463
+ isSessionActive = false
2464
+ controls.classList.remove('is-dragging-col')
2465
+ thumbColBtn.style.left = initialThumbLeft
2466
+ },
2467
+ })
2468
+ }
2469
+ return
2470
+ }
2471
+
2472
+ // 3. Handle column border drag-resizing via usePointerSession (with Escape rollback)
2473
+ const hovered = getHoveredResizeCell(e)
2474
+ if (!hovered || !hovered.cell.nextElementSibling) { return }
2475
+
2476
+ const { cell, table } = hovered
2477
+ const map = buildTableGrid(table)
2478
+ const coords = findCellCoords(map, cell)
2479
+ if (!coords) { return }
2480
+ const colIndex = coords.col + coords.colspan - 1
2481
+ if (colIndex >= map.colCount - 1) { return }
2482
+
2483
+ const tableRect = table.getBoundingClientRect()
2484
+ const cols = Array.from(table.querySelectorAll<HTMLElement>(':scope > colgroup > col'))
2485
+ const startWidths = cols.length === map.colCount
2486
+ ? cols.map(c => Number.parseFloat(c.style.width) || (100 / map.colCount))
2487
+ : Array.from({ length: map.colCount }, () => Number((100 / map.colCount).toFixed(2)))
2488
+
2489
+ isSessionActive = true
2490
+ hideAllInlineControls()
2491
+ tablePointerSession.start(e, {
2492
+ target: cell,
2493
+ cursor: 'col-resize',
2494
+ onMove: (_moveEvent, { dx }) => {
2495
+ if (tableRect.width <= 0) { return }
2496
+ const isRTL = table.dir === 'rtl'
2497
+ const deltaPx = isRTL ? -dx : dx
2498
+ const deltaPct = (deltaPx / tableRect.width) * 100
2499
+
2500
+ const nextIdx = colIndex + 1
2501
+ if (nextIdx >= startWidths.length) { return }
2502
+
2503
+ const minPct = 5
2504
+ const pairTotal = startWidths[colIndex] + startWidths[nextIdx]
2505
+ const newCurrent = Math.max(minPct, Math.min(pairTotal - minPct, startWidths[colIndex] + deltaPct))
2506
+ const newNext = pairTotal - newCurrent
2507
+
2508
+ const updatedWidths = [...startWidths]
2509
+ updatedWidths[colIndex] = Number(newCurrent.toFixed(2))
2510
+ updatedWidths[nextIdx] = Number(newNext.toFixed(2))
2511
+ applyColumnWidthsToDOM(table, updatedWidths)
2512
+ },
2513
+ onEnd: () => {
2514
+ isSessionActive = false
2515
+ updateContentWithHistory(doc)
2516
+ },
2517
+ onCancel: () => {
2518
+ isSessionActive = false
2519
+ applyColumnWidthsToDOM(table, startWidths)
2520
+ },
2521
+ })
2522
+ }
2523
+
2524
+ doc.addEventListener('pointermove', onHoverPointerMove)
2525
+ doc.addEventListener('pointerdown', onPointerDown)
2526
+
2527
+ activeResizeCleanup = () => {
2528
+ tablePointerSession.cancel()
2529
+ doc.removeEventListener('pointermove', onHoverPointerMove)
2530
+ doc.removeEventListener('pointerdown', onPointerDown)
2531
+ }
2532
+ }
2533
+
2534
+ function setupTableEditButtons(doc: Document) {
2535
+ const previousEditHandler = (doc as any).__tableEditPointerHandler as ((event: PointerEvent) => void) | undefined
2536
+ if (previousEditHandler) {
2537
+ doc.removeEventListener('pointerdown', previousEditHandler, true)
2538
+ }
2539
+
2540
+ const tableEditPointerHandler = (event: PointerEvent) => {
2541
+ const target = event.target as Element | null
2542
+ const editButton = target?.closest('.table-edit-btn')
2543
+ if (!editButton) { return }
2544
+
2545
+ const table = editButton.closest('.table-wrapper')?.querySelector('table') as HTMLTableElement | null
2546
+ if (!table) { return }
2547
+
2548
+ event.preventDefault()
2549
+ event.stopPropagation()
2550
+ event.stopImmediatePropagation()
2551
+ globalThis.setTimeout(() => {
2552
+ try {
2553
+ openTableEditor(table)
2554
+ } catch (error) {
2555
+ console.error('[RichText table editor] delegated open failed', error)
2556
+ }
2557
+ }, 0)
2558
+ }
2559
+
2560
+ ;(doc as any).__tableEditPointerHandler = tableEditPointerHandler
2561
+ doc.addEventListener('pointerdown', tableEditPointerHandler, true)
2562
+ setupTableColumnResizing(doc)
2563
+
2564
+ function createInlineColumnControls(): HTMLElement {
2565
+ const controls = doc.createElement('div')
2566
+ controls.className = 'table-inline-controls'
2567
+ controls.setAttribute('contenteditable', 'false')
2568
+
2569
+ const addLine = doc.createElement('span')
2570
+ addLine.className = 'table-col-add-line'
2571
+ addLine.setAttribute('contenteditable', 'false')
2572
+
2573
+ const addBtn = doc.createElement('span')
2574
+ addBtn.className = 'table-col-add-btn'
2575
+ addBtn.textContent = '+'
2576
+ addBtn.title = 'Insert column here'
2577
+ addBtn.setAttribute('aria-label', 'Insert column here')
2578
+ addBtn.setAttribute('contenteditable', 'false')
2579
+ addBtn.setAttribute('tabindex', '-1')
2580
+
2581
+ const thumbBtn = doc.createElement('span')
2582
+ thumbBtn.className = 'table-col-thumb'
2583
+ thumbBtn.textContent = '⋯'
2584
+ thumbBtn.title = 'Click for column options • Drag to reorder column'
2585
+ thumbBtn.setAttribute('aria-label', 'Column options')
2586
+ thumbBtn.setAttribute('contenteditable', 'false')
2587
+ thumbBtn.setAttribute('tabindex', '-1')
2588
+
2589
+ controls.appendChild(addLine)
2590
+ controls.appendChild(addBtn)
2591
+ controls.appendChild(thumbBtn)
2592
+ return controls
2593
+ }
2594
+
2595
+ // Simple function to add edit buttons to all tables
2596
+ function addEditButtonsToTables() {
2597
+ const tables = doc.querySelectorAll<HTMLTableElement>('table:not([data-edit-button-added])')
2598
+
2599
+ tables.forEach((table) => {
2600
+ const editBtn = doc.createElement('span')
2601
+ editBtn.className = 'table-edit-btn'
2602
+ editBtn.textContent = '✎'
2603
+ editBtn.title = 'Edit Table'
2604
+ editBtn.setAttribute('aria-label', 'Edit Table')
2605
+
2606
+ editBtn.setAttribute('contenteditable', 'false')
2607
+ editBtn.setAttribute('tabindex', '-1')
2608
+ editBtn.style.pointerEvents = 'auto'
2609
+ editBtn.style.userSelect = 'none'
2610
+
2611
+ editBtn.addEventListener('click', (e) => {
2612
+ e.preventDefault()
2613
+ e.stopPropagation()
2614
+ e.stopImmediatePropagation()
2615
+ })
2616
+
2617
+ editBtn.addEventListener('focus', (e) => {
2618
+ e.preventDefault()
2619
+ e.stopPropagation()
2620
+ editBtn.blur()
2621
+ })
2622
+
2623
+ editBtn.addEventListener('pointerdown', (e) => {
2624
+ e.preventDefault()
2625
+ e.stopPropagation()
2626
+ e.stopImmediatePropagation()
2627
+ })
2628
+
2629
+ const existingWrapper = table.closest('.table-wrapper') as HTMLElement | null
2630
+ if (existingWrapper) {
2631
+ if (!existingWrapper.querySelector('.table-edit-btn')) {
2632
+ existingWrapper.appendChild(editBtn)
2633
+ }
2634
+ if (!existingWrapper.querySelector('.table-inline-controls')) {
2635
+ existingWrapper.appendChild(createInlineColumnControls())
2636
+ }
2637
+ table.setAttribute('data-edit-button-added', 'true')
2638
+ return
2639
+ }
2480
2640
 
2481
- // Create a wrapper div to contain both table and button
2482
2641
  const wrapper = doc.createElement('div')
2483
2642
  wrapper.className = 'table-wrapper'
2484
2643
  wrapper.style.position = 'relative'
@@ -2487,9 +2646,7 @@ function setupTableEditButtons(doc: Document) {
2487
2646
  wrapper.style.width = '100%'
2488
2647
  wrapper.style.marginBottom = '1rem'
2489
2648
 
2490
- // Copy table alignment to wrapper and convert to flex
2491
2649
  if (table.style.marginLeft && table.style.marginRight) {
2492
- // Detect current alignment
2493
2650
  let alignment = 'left'
2494
2651
  if (table.style.marginLeft === 'auto' && table.style.marginRight === 'auto') {
2495
2652
  alignment = 'center'
@@ -2497,7 +2654,6 @@ function setupTableEditButtons(doc: Document) {
2497
2654
  alignment = 'right'
2498
2655
  }
2499
2656
 
2500
- // Apply flex alignment to wrapper
2501
2657
  if (alignment === 'center') {
2502
2658
  wrapper.style.alignItems = 'center'
2503
2659
  } else if (alignment === 'right') {
@@ -2506,42 +2662,38 @@ function setupTableEditButtons(doc: Document) {
2506
2662
  wrapper.style.alignItems = 'flex-start'
2507
2663
  }
2508
2664
 
2509
- // Reset table margins since wrapper handles alignment now
2510
2665
  table.style.marginLeft = '0'
2511
2666
  table.style.marginRight = '0'
2512
2667
  } else {
2513
- // Default to left alignment
2514
2668
  wrapper.style.alignItems = 'flex-start'
2515
2669
  }
2516
2670
 
2517
- // Insert wrapper before table
2518
2671
  table.parentNode?.insertBefore(wrapper, table)
2519
- // Move table into wrapper
2520
2672
  wrapper.appendChild(table)
2521
- // Add button to wrapper (not to table)
2522
2673
  wrapper.appendChild(editBtn)
2674
+ wrapper.appendChild(createInlineColumnControls())
2523
2675
 
2524
- // Remove margin from table since wrapper handles it
2525
2676
  table.style.marginBottom = '0'
2526
-
2527
2677
  table.setAttribute('data-edit-button-added', 'true')
2528
2678
  })
2529
2679
  }
2530
2680
 
2531
- // Store function for external access
2532
- ; (doc as any).__addEditButtonsToTables = addEditButtonsToTables
2533
-
2534
- // Add buttons immediately
2681
+ ;(doc as any).__addEditButtonsToTables = addEditButtonsToTables
2535
2682
  addEditButtonsToTables()
2536
2683
  }
2537
2684
 
2538
2685
  function setupAutoWrapping(doc: Document) {
2539
- // Function to remove placeholder
2686
+ function createPlaceholderElement(): HTMLParagraphElement {
2687
+ const p = doc.createElement('p')
2688
+ p.className = 'placeholder'
2689
+ p.textContent = props.placeholder || ''
2690
+ return p
2691
+ }
2692
+
2540
2693
  function removePlaceholder() {
2541
2694
  const placeholderElement = doc.querySelector('.placeholder')
2542
2695
  if (placeholderElement) {
2543
2696
  placeholderElement.remove()
2544
- // Add empty paragraph if body becomes empty
2545
2697
  if (!doc.body.innerHTML.trim()) {
2546
2698
  const direction = getCurrentDirection()
2547
2699
  doc.body.innerHTML = `<p dir="${direction}"><br></p>`
@@ -2549,16 +2701,14 @@ function setupAutoWrapping(doc: Document) {
2549
2701
  }
2550
2702
  }
2551
2703
 
2552
- // Function to add placeholder if content is empty
2553
2704
  function addPlaceholder() {
2554
2705
  const direction = getCurrentDirection()
2555
2706
  const emptyContent = `<p dir="${direction}"><br></p>`
2556
2707
  if (props.placeholder && (!doc.body.innerHTML.trim() || doc.body.innerHTML === emptyContent)) {
2557
- doc.body.innerHTML = `<p class="placeholder">${props.placeholder}</p>`
2708
+ doc.body.replaceChildren(createPlaceholderElement())
2558
2709
  }
2559
2710
  }
2560
2711
 
2561
- // Initialize editor with paragraph or placeholder
2562
2712
  if (!doc.body.innerHTML.trim()) {
2563
2713
  if (props.placeholder) {
2564
2714
  addPlaceholder()
@@ -2568,34 +2718,27 @@ function setupAutoWrapping(doc: Document) {
2568
2718
  }
2569
2719
  }
2570
2720
 
2571
- // After any change, ensure proper structure
2572
2721
  function normalizeContent() {
2573
- // Only proceed if the body exists
2574
2722
  if (!doc.body) {
2575
2723
  return
2576
2724
  }
2577
2725
 
2578
- // If body is completely empty, add a paragraph and return
2579
2726
  if (!doc.body.innerHTML.trim() || doc.body.innerHTML === '') {
2580
2727
  const direction = getCurrentDirection()
2581
2728
  doc.body.innerHTML = `<p dir="${direction}"><br></p>`
2582
2729
  return
2583
2730
  }
2584
2731
 
2585
- // Mark body as being normalized to prevent recursive processing
2586
2732
  if (doc.body.dataset.normalizing === 'true') {
2587
2733
  return
2588
2734
  }
2589
2735
  doc.body.dataset.normalizing = 'true'
2590
2736
 
2591
2737
  try {
2592
- // Only wrap loose text nodes that are direct children of body
2593
2738
  const walker = doc.createTreeWalker(
2594
2739
  doc.body,
2595
2740
  NodeFilter.SHOW_TEXT,
2596
2741
  (node) => {
2597
- // Only accept text nodes that are direct children of body
2598
- // AND have meaningful content
2599
2742
  const parent = node.parentNode as HTMLElement
2600
2743
  return (parent === doc.body
2601
2744
  && node.textContent?.trim()
@@ -2611,30 +2754,22 @@ function setupAutoWrapping(doc: Document) {
2611
2754
  textNodes.push(node as Text)
2612
2755
  }
2613
2756
 
2614
- // Wrap loose text nodes very carefully, preserving existing structure
2615
2757
  textNodes.forEach((textNode) => {
2616
- // Triple check the node is still valid and not corrupted
2617
2758
  if (textNode.parentNode === doc.body
2618
2759
  && textNode.textContent?.trim()
2619
2760
  && textNode.nodeType === Node.TEXT_NODE) {
2620
2761
  const p = doc.createElement('p')
2621
2762
  p.dir = doc.body.dir || 'ltr'
2763
+ p.textContent = textNode.textContent
2622
2764
 
2623
- // Clone the text content to avoid any reference issues
2624
- const { textContent } = textNode
2625
- p.textContent = textContent
2626
-
2627
- // Replace the text node with the paragraph
2628
2765
  try {
2629
2766
  textNode.parentNode.replaceChild(p, textNode)
2630
- } catch (error) {
2631
- // If replacement fails, just remove the problematic text node
2767
+ } catch {
2632
2768
  textNode.parentNode.removeChild(textNode)
2633
2769
  }
2634
2770
  }
2635
2771
  })
2636
2772
 
2637
- // Ensure empty body has a paragraph or placeholder
2638
2773
  if (!doc.body.children.length || !doc.body.innerHTML.trim()) {
2639
2774
  if (props.placeholder) {
2640
2775
  addPlaceholder()
@@ -2644,39 +2779,36 @@ function setupAutoWrapping(doc: Document) {
2644
2779
  }
2645
2780
  }
2646
2781
 
2647
- // Clean up empty paragraphs more conservatively
2648
- const paras = Array.from(doc.body.querySelectorAll('p'))
2782
+ const paras = Array.from(doc.body.querySelectorAll(':scope > p'))
2649
2783
  for (let i = 0; i < paras.length; i++) {
2650
2784
  const current = paras[i]
2651
- const isEmpty = !current.textContent?.trim() || current.innerHTML === '<br>'
2785
+ const isEmpty = !current.textContent?.trim() && !current.querySelector('img, video, iframe')
2652
2786
 
2653
2787
  if (isEmpty) {
2654
- // Only remove if we have consecutive empty paragraphs at the end
2655
2788
  const isLastPara = i === paras.length - 1
2656
2789
  const prevPara = i > 0 ? paras[i - 1] : null
2657
- const isPrevEmpty = prevPara && (!prevPara.textContent?.trim() || prevPara.innerHTML === '<br>')
2790
+ const isPrevEmpty = prevPara && !prevPara.textContent?.trim() && !prevPara.querySelector('img, video, iframe')
2658
2791
 
2659
- // Keep at least one empty paragraph for cursor placement
2660
2792
  if (isPrevEmpty && !isLastPara) {
2661
2793
  current.remove()
2662
2794
  }
2663
2795
  }
2664
2796
  }
2665
2797
  } finally {
2666
- // Always clean up the processing flag
2667
2798
  delete doc.body.dataset.normalizing
2668
2799
  }
2669
2800
  }
2670
2801
 
2671
2802
  // Handle input events
2672
2803
  doc.addEventListener('input', (e) => {
2673
- // Remove placeholder on first input
2674
- removePlaceholder()
2804
+ const inputEvent = e as InputEvent
2805
+ if (inputEvent.isComposing) {
2806
+ return
2807
+ }
2675
2808
 
2676
- // Detect direction based on content
2809
+ removePlaceholder()
2677
2810
  detectAndSetDirection()
2678
2811
 
2679
- // Handle complete content deletion immediately
2680
2812
  if (!doc.body.innerHTML.trim() || doc.body.innerHTML === '') {
2681
2813
  if (props.placeholder) {
2682
2814
  addPlaceholder()
@@ -2686,175 +2818,146 @@ function setupAutoWrapping(doc: Document) {
2686
2818
  }
2687
2819
  updateContentWithHistory(doc)
2688
2820
  return
2689
- } // Don't normalize during normal typing - only on paste/drop
2690
- const inputEvent = e as InputEvent
2691
- const normalizeInputTypes = ['insertFromPaste', 'insertFromDrop']
2821
+ }
2692
2822
 
2693
- // Only normalize on paste/drop operations to avoid interfering with typing
2823
+ const normalizeInputTypes = ['insertFromPaste', 'insertFromDrop']
2694
2824
  if (inputEvent.inputType && normalizeInputTypes.includes(inputEvent.inputType)) {
2695
- // Add a delay to let the browser finish processing
2696
2825
  setTimeout(() => {
2697
2826
  normalizeContent()
2698
- }, 100)
2827
+ setupTableEditButtons(doc)
2828
+ updateContentWithHistory(doc)
2829
+ }, 50)
2830
+ return
2699
2831
  }
2700
2832
 
2701
- // Always update content to keep state in sync
2702
2833
  updateContentWithHistory(doc)
2703
2834
  })
2704
2835
 
2705
- // Handle Enter key
2836
+ // Handle Enter key safely across inline trees, selections, IME, and tables
2706
2837
  doc.addEventListener('keydown', (e) => {
2707
- if (e.key === 'Enter') {
2708
- const selection = doc.getSelection()
2709
- if (!selection || !selection.rangeCount) { return }
2710
-
2711
- const range = selection.getRangeAt(0)
2712
-
2713
- // Check if we're in any list context - let browser handle lists
2714
- let currentElement = range.startContainer
2715
-
2716
- // If it's a text node, get its parent
2717
- if (currentElement.nodeType === Node.TEXT_NODE) {
2718
- currentElement = currentElement.parentElement!
2719
- }
2838
+ if (e.key !== 'Enter') { return }
2839
+ if (e.isComposing || e.keyCode === 229 || e.shiftKey) { return }
2720
2840
 
2721
- // Check if we're in a list item or list container
2722
- const li = (currentElement as Element).closest('li')
2723
- const listContainer = (currentElement as Element).closest('ul, ol')
2841
+ const selection = doc.getSelection()
2842
+ if (!selection || !selection.rangeCount) { return }
2724
2843
 
2725
- if (li || listContainer) {
2726
- // We're in a list context - let browser handle everything
2727
- return
2728
- } // Find the paragraph we're in (only for confirmed non-list content)
2729
- let paragraph = range.startContainer
2730
- if (paragraph.nodeType === Node.TEXT_NODE) {
2731
- paragraph = paragraph.parentElement!
2732
- }
2733
- paragraph = (paragraph as Element).closest('p,h1,h2,h3,h4,h5,h6') as HTMLElement
2844
+ const range = selection.getRangeAt(0)
2734
2845
 
2735
- if (!paragraph) { return }
2846
+ let currentElement = range.startContainer
2847
+ if (currentElement.nodeType === Node.TEXT_NODE) {
2848
+ currentElement = currentElement.parentElement!
2849
+ }
2736
2850
 
2737
- // Check if current paragraph is empty and previous paragraph is also empty
2738
- const currentIsEmpty = !paragraph.textContent?.trim()
2739
- const prevSibling = (paragraph as Element).previousElementSibling as HTMLElement
2740
- const prevIsEmpty = prevSibling
2741
- && (prevSibling.tagName === 'P')
2742
- && !prevSibling.textContent?.trim()
2851
+ // Let browser handle lists and table cells natively
2852
+ if ((currentElement as Element)?.closest('li, ul, ol, td, th')) {
2853
+ return
2854
+ }
2743
2855
 
2744
- // If both current and previous paragraphs are empty, don't create another empty paragraph
2745
- if (currentIsEmpty && prevIsEmpty) {
2746
- e.preventDefault()
2747
- return
2748
- }
2856
+ const paragraph = (currentElement as Element)?.closest('p,h1,h2,h3,h4,h5,h6') as HTMLElement | null
2857
+ if (!paragraph) { return }
2749
2858
 
2750
- // For RTL text, be more careful about splitting
2751
- const paragraphDirection = getTextDirection(
2752
- paragraph.textContent || '',
2753
- (doc.body.dir || 'ltr') as 'ltr' | 'rtl'
2754
- )
2859
+ const currentIsEmpty = !paragraph.textContent?.trim() && !paragraph.querySelector('img, video, iframe')
2860
+ const prevSibling = paragraph.previousElementSibling as HTMLElement | null
2861
+ const prevIsEmpty = prevSibling
2862
+ && prevSibling.tagName === 'P'
2863
+ && !prevSibling.textContent?.trim()
2864
+ && !prevSibling.querySelector('img, video, iframe')
2755
2865
 
2756
- // Prevent default and handle manually to avoid browser inconsistencies
2866
+ if (currentIsEmpty && prevIsEmpty) {
2757
2867
  e.preventDefault()
2758
- e.stopPropagation()
2759
-
2760
- // Get the text content before and after cursor
2761
- const textBeforeCursor = range.startContainer.textContent?.substring(0, range.startOffset) || ''
2762
- const textAfterCursor = range.startContainer.textContent?.substring(range.startOffset) || ''
2868
+ return
2869
+ }
2763
2870
 
2764
- // Debug logging to understand the issue
2765
-
2766
- if (props.debug) { /* reserved for future debug output */ }
2871
+ e.preventDefault()
2872
+ e.stopPropagation()
2767
2873
 
2768
- // Create new paragraph with proper direction
2769
- const newParagraph = doc.createElement('p')
2770
- newParagraph.dir = paragraphDirection
2874
+ // Delete selected content first if selection is not collapsed
2875
+ if (!range.collapsed) {
2876
+ range.deleteContents()
2877
+ }
2771
2878
 
2772
- // If we're splitting text, handle it carefully
2773
- if (range.startContainer.nodeType === Node.TEXT_NODE && textAfterCursor.trim()) {
2774
- // We're in the middle of text - split properly
2775
- const textNode = range.startContainer as Text
2879
+ const paragraphDirection = paragraph.getAttribute('dir') as 'ltr' | 'rtl'
2880
+ || getTextDirection(paragraph.textContent || '', (doc.body.dir || 'ltr') as 'ltr' | 'rtl')
2776
2881
 
2777
- // Store the original parent element for formatting preservation
2778
- const { parentElement } = textNode
2882
+ const newParagraph = doc.createElement('p')
2883
+ newParagraph.dir = paragraphDirection
2884
+ if (paragraph.hasAttribute('data-dir-explicit')) {
2885
+ newParagraph.setAttribute('data-dir-explicit', 'true')
2886
+ }
2887
+ if (paragraph.style.textAlign) {
2888
+ newParagraph.style.textAlign = paragraph.style.textAlign
2889
+ }
2779
2890
 
2780
- // Keep text before cursor in current paragraph
2781
- textNode.textContent = textBeforeCursor
2891
+ // Extract all DOM nodes from the caret position to the end of `paragraph`
2892
+ const tailRange = doc.createRange()
2893
+ tailRange.setStart(range.startContainer, range.startOffset)
2894
+ tailRange.setEnd(paragraph, paragraph.childNodes.length)
2895
+ const trailingFragment = tailRange.extractContents()
2782
2896
 
2783
- // Put text after cursor in new paragraph
2784
- if (textAfterCursor.trim()) {
2785
- // Preserve any formatting from the parent element
2786
- if (parentElement && parentElement !== paragraph) {
2787
- const newFormattedElement = parentElement.cloneNode(false) as HTMLElement
2788
- newFormattedElement.textContent = textAfterCursor
2789
- newParagraph.appendChild(newFormattedElement)
2790
- } else {
2791
- newParagraph.textContent = textAfterCursor
2792
- }
2793
- } else {
2794
- newParagraph.innerHTML = '<br>'
2795
- }
2796
- } else {
2797
- // We're at the end of text or in empty space
2798
- newParagraph.innerHTML = '<br>'
2897
+ // Strip empty inline wrappers left behind at the split boundary
2898
+ Array.from(paragraph.querySelectorAll('b, i, u, strong, em, span, a')).forEach((el) => {
2899
+ if (!el.textContent && !el.querySelector('img, br')) {
2900
+ el.remove()
2799
2901
  }
2902
+ })
2903
+ Array.from(trailingFragment.querySelectorAll('b, i, u, strong, em, span, a')).forEach((el) => {
2904
+ if (!el.textContent && !el.querySelector('img, br')) {
2905
+ el.remove()
2906
+ }
2907
+ })
2800
2908
 
2801
- // Insert the new paragraph
2802
- paragraph.parentNode?.insertBefore(newParagraph, paragraph.nextSibling)
2803
-
2804
- // Set cursor at the beginning of the new paragraph
2805
- const newRange = doc.createRange()
2806
- newRange.selectNodeContents(newParagraph)
2807
- newRange.collapse(true)
2808
- selection.removeAllRanges()
2809
- selection.addRange(newRange)
2909
+ if (!paragraph.textContent?.trim() && !paragraph.querySelector('img, br, video, iframe')) {
2910
+ paragraph.innerHTML = '<br>'
2911
+ }
2810
2912
 
2811
- // Update content immediately to reflect changes
2812
- updateContentWithHistory(doc)
2913
+ if (trailingFragment.textContent?.trim() || trailingFragment.querySelector('img, video, iframe')) {
2914
+ newParagraph.appendChild(trailingFragment)
2915
+ } else {
2916
+ newParagraph.innerHTML = '<br>'
2813
2917
  }
2918
+
2919
+ paragraph.parentNode?.insertBefore(newParagraph, paragraph.nextSibling)
2920
+
2921
+ const newRange = doc.createRange()
2922
+ newRange.selectNodeContents(newParagraph)
2923
+ newRange.collapse(true)
2924
+ selection.removeAllRanges()
2925
+ selection.addRange(newRange)
2926
+
2927
+ updateContentWithHistory(doc)
2814
2928
  })
2815
2929
 
2816
- // Handle paste
2817
- doc.addEventListener('paste', (e) => {
2818
- // Give the paste operation time to complete before normalizing
2819
- setTimeout(() => {
2820
- normalizeContent()
2821
- updateContentWithHistory(doc)
2822
- }, 150) // Longer timeout for reliable paste processing
2823
- }) // Add a MutationObserver to catch structural changes that might create loose text
2824
- const observer = new MutationObserver((mutations) => {
2930
+ // Disconnect any previous observer before attaching a new one
2931
+ bodyMutationObserver?.disconnect()
2932
+ bodyMutationObserver = new MutationObserver((mutations) => {
2825
2933
  for (const mutation of mutations) {
2826
2934
  if (mutation.type === 'childList') {
2827
- // Check if any loose text nodes were added to body
2828
2935
  Array.from(mutation.addedNodes).forEach((addedNode) => {
2829
2936
  if (addedNode.nodeType === Node.TEXT_NODE
2830
2937
  && addedNode.parentNode === doc.body
2831
2938
  && addedNode.textContent?.trim()) {
2832
- // Wrap loose text node immediately
2833
2939
  const p = doc.createElement('p')
2834
2940
  p.dir = doc.body.dir || 'ltr'
2835
2941
  p.textContent = addedNode.textContent
2836
2942
  addedNode.parentNode.replaceChild(p, addedNode)
2837
2943
  updateContentWithHistory(doc)
2838
- }
2839
- // Check if a table was added
2840
- else if (addedNode.nodeType === Node.ELEMENT_NODE) {
2944
+ } else if (addedNode.nodeType === Node.ELEMENT_NODE) {
2841
2945
  const element = addedNode as HTMLElement
2842
- if (element.tagName === 'TABLE') {
2946
+ const tables = element.tagName === 'TABLE'
2947
+ ? [element as HTMLTableElement]
2948
+ : Array.from(element.querySelectorAll<HTMLTableElement>('table'))
2949
+ if (tables.length > 0) {
2843
2950
  setTimeout(() => {
2844
- applyDefaultTableSettings(element as HTMLTableElement)
2951
+ tables.forEach(t => applyDefaultTableSettings(t))
2845
2952
  setupTableEditButtons(doc)
2846
- }, 100)
2953
+ }, 20)
2847
2954
  }
2848
- // Apply direction to blockquotes and lists
2849
- else if (['BLOCKQUOTE', 'UL', 'OL'].includes(element.tagName)) {
2955
+ if (['BLOCKQUOTE', 'UL', 'OL'].includes(element.tagName)) {
2850
2956
  if (!element.dir) {
2851
2957
  element.dir = doc.body.dir || 'ltr'
2852
2958
  }
2853
-
2854
- // Also apply direction to list items if it's a list
2855
2959
  if (element.tagName === 'UL' || element.tagName === 'OL') {
2856
- const listItems = element.querySelectorAll('li')
2857
- listItems.forEach((li) => {
2960
+ element.querySelectorAll('li').forEach((li) => {
2858
2961
  if (!li.dir) {
2859
2962
  li.dir = element.dir
2860
2963
  }
@@ -2867,17 +2970,11 @@ function setupAutoWrapping(doc: Document) {
2867
2970
  }
2868
2971
  })
2869
2972
 
2870
- // Start observing
2871
- observer.observe(doc.body, {
2973
+ bodyMutationObserver.observe(doc.body, {
2872
2974
  childList: true,
2873
- subtree: false // Only watch direct children of body
2975
+ subtree: true
2874
2976
  })
2875
2977
 
2876
- // Store observer for cleanup
2877
- if (!doc.body.dataset.observers) {
2878
- doc.body.dataset.observers = 'mutation'
2879
- }
2880
-
2881
2978
  // Handle clicks on links to open link modal
2882
2979
  doc.addEventListener('click', (e) => {
2883
2980
  const target = e.target as HTMLElement
@@ -3052,7 +3149,7 @@ async function initEditor() {
3052
3149
  }
3053
3150
  body {
3054
3151
  margin: 0;
3055
- padding: 8px;
3152
+ padding: 14px 8px 8px 8px;
3056
3153
  min-height: 200px;
3057
3154
  font-family: ${currentFontFamily.value};
3058
3155
  line-height: 1.5;
@@ -3140,6 +3237,116 @@ async function initEditor() {
3140
3237
  .table-edit-btn:active {
3141
3238
  background: #1f1f1f;
3142
3239
  }
3240
+
3241
+ /* Native inline column add (+) and draggable column thumb (⋯) handles */
3242
+ .table-inline-controls {
3243
+ position: absolute;
3244
+ inset: 0;
3245
+ pointer-events: none;
3246
+ user-select: none;
3247
+ z-index: 95;
3248
+ }
3249
+
3250
+ .table-col-add-btn,
3251
+ .table-col-thumb {
3252
+ position: absolute;
3253
+ display: flex;
3254
+ align-items: center;
3255
+ justify-content: center;
3256
+ font-family: sans-serif;
3257
+ font-weight: 600;
3258
+ line-height: 1;
3259
+ pointer-events: auto;
3260
+ user-select: none;
3261
+ opacity: 0;
3262
+ visibility: hidden;
3263
+ transform: translate(-50%, -50%) scale(0.9);
3264
+ transition: opacity 0.14s ease, transform 0.14s ease, background-color 0.14s ease, border-color 0.14s ease, visibility 0.14s ease;
3265
+ }
3266
+
3267
+ .table-col-add-btn {
3268
+ width: 20px;
3269
+ height: 20px;
3270
+ border-radius: 999px;
3271
+ background: #007bff;
3272
+ color: #fff !important;
3273
+ border: 2px solid #fff;
3274
+ font-size: 14px;
3275
+ cursor: pointer;
3276
+ box-shadow: 0 2px 6px rgba(0, 123, 255, 0.35);
3277
+ z-index: 98;
3278
+ }
3279
+
3280
+ .table-col-add-btn:hover {
3281
+ background: #0056b3;
3282
+ transform: translate(-50%, -50%) scale(1.08) !important;
3283
+ }
3284
+
3285
+ .table-col-add-line {
3286
+ position: absolute;
3287
+ width: 2px;
3288
+ background: #007bff;
3289
+ pointer-events: none;
3290
+ opacity: 0;
3291
+ visibility: hidden;
3292
+ transform: translateX(-50%);
3293
+ transition: opacity 0.14s ease, visibility 0.14s ease;
3294
+ z-index: 94;
3295
+ }
3296
+
3297
+ .table-col-thumb {
3298
+ width: 28px;
3299
+ height: 14px;
3300
+ border-radius: 999px;
3301
+ background: #f3f4f6;
3302
+ color: #4b5563 !important;
3303
+ border: 1px solid #d1d5db;
3304
+ font-size: 13px;
3305
+ letter-spacing: -1px;
3306
+ cursor: grab;
3307
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
3308
+ z-index: 96;
3309
+ }
3310
+
3311
+ .table-col-thumb:hover {
3312
+ background: #007bff;
3313
+ color: #fff !important;
3314
+ border-color: #007bff;
3315
+ transform: translate(-50%, -50%) scale(1.05) !important;
3316
+ }
3317
+
3318
+ .table-col-thumb:active,
3319
+ .table-inline-controls.is-dragging-col .table-col-thumb {
3320
+ cursor: grabbing;
3321
+ background: #0056b3;
3322
+ color: #fff !important;
3323
+ border-color: #0056b3;
3324
+ }
3325
+
3326
+ .table-wrapper:hover .table-inline-controls.is-visible.show-col-add .table-col-add-btn {
3327
+ opacity: 1;
3328
+ visibility: visible;
3329
+ transform: translate(-50%, -50%) scale(1);
3330
+ }
3331
+
3332
+ .table-wrapper:hover .table-inline-controls.is-visible.show-col-add .table-col-add-line,
3333
+ .table-inline-controls.is-dragging-col .table-col-add-line {
3334
+ opacity: 0.45;
3335
+ visibility: visible;
3336
+ }
3337
+
3338
+ .table-wrapper:hover .table-inline-controls.is-visible.show-col-add:has(.table-col-add-btn:hover) .table-col-add-line,
3339
+ .table-inline-controls.is-dragging-col .table-col-add-line {
3340
+ opacity: 0.9;
3341
+ visibility: visible;
3342
+ }
3343
+
3344
+ .table-wrapper:hover .table-inline-controls.is-visible.show-col-thumb .table-col-thumb,
3345
+ .table-inline-controls.is-dragging-col .table-col-thumb {
3346
+ opacity: 1;
3347
+ visibility: visible;
3348
+ transform: translate(-50%, -50%) scale(1);
3349
+ }
3143
3350
  .richtext-editor-content blockquote {
3144
3351
  padding: 8px !important;
3145
3352
  background: #f4f4f4 !important;
@@ -3257,50 +3464,52 @@ async function initEditor() {
3257
3464
  opacity: 1 !important;
3258
3465
  background: #007bff !important;
3259
3466
  }
3467
+
3468
+ .table-cell-selected {
3469
+ background-color: rgba(0, 123, 255, 0.08) !important;
3470
+ outline: 2px solid #007bff !important;
3471
+ outline-offset: -2px !important;
3472
+ }
3473
+
3474
+ .placeholder {
3475
+ color: #9ca3af;
3476
+ font-style: italic;
3477
+ pointer-events: none;
3478
+ user-select: none;
3479
+ }
3260
3480
  ` // Create a complete HTML document with proper doctype and meta tags
3261
- const initialContent = normalizedModelValue.value || (props.placeholder ? `<p class="placeholder">${props.placeholder}</p>` : '')
3262
3481
  const htmlContent = `
3263
3482
  <!DOCTYPE html>
3264
3483
  <html>
3265
3484
  <head>
3266
3485
  <meta charset="UTF-8">
3267
3486
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3268
- <meta http-equiv="Content-Security-Policy" content="
3269
- default-src * data: blob: 'unsafe-inline' 'unsafe-eval';
3270
- img-src * data: blob:;
3271
- style-src * 'unsafe-inline';
3272
- script-src * 'unsafe-inline' 'unsafe-eval';
3273
- frame-src * data: blob:;
3274
- connect-src *;
3275
- media-src *;
3276
- ">
3277
3487
  <base target="_blank">
3278
3488
  <style id="editor-styles">
3279
-
3280
3489
  ${editorStylesContent}
3281
- .placeholder {
3282
- color: #9ca3af;
3283
- font-style: italic;
3284
- pointer-events: none;
3285
- user-select: none;
3286
- }
3287
-
3288
3490
  </style>
3289
3491
  </head>
3290
- <body class="richtext-editor-content">${initialContent}</body>
3492
+ <body class="richtext-editor-content"></body>
3291
3493
  </html>
3292
3494
  `
3293
3495
 
3294
- // Write the complete HTML document to the iframe
3496
+ // Write the HTML shell to the iframe, then populate body via DOM
3295
3497
  const doc = iframe.value.contentDocument || iframe.value.contentWindow?.document
3296
3498
  if (!doc) { return }
3297
3499
 
3298
- // First write the content
3299
3500
  doc.open()
3300
- // ! @deprecated — MDN Reference[https://developer.mozilla.org/en-US/docs/Web/API/Document/write]
3301
3501
  doc.write(htmlContent)
3302
3502
  doc.close()
3303
3503
 
3504
+ if (normalizedModelValue.value) {
3505
+ doc.body.innerHTML = normalizedModelValue.value
3506
+ } else if (props.placeholder) {
3507
+ const placeholderEl = doc.createElement('p')
3508
+ placeholderEl.className = 'placeholder'
3509
+ placeholderEl.textContent = props.placeholder
3510
+ doc.body.appendChild(placeholderEl)
3511
+ }
3512
+
3304
3513
  // Then make it editable after a short delay
3305
3514
  await new Promise(resolve => setTimeout(resolve, 0))
3306
3515
 
@@ -3311,16 +3520,12 @@ async function initEditor() {
3311
3520
  doc.body.dir = hasRTL.value ? 'rtl' : 'ltr'
3312
3521
  doc.body.style.direction = doc.body.dir
3313
3522
 
3314
- // Resolve bidirectional text independently for each block. Using the
3315
- // first strong character keeps English fragments stable inside Hebrew
3316
- // text (and vice versa) without forcing one direction on the whole document.
3317
3523
  doc.body.querySelectorAll('p,h1,h2,h3,h4,h5,h6,blockquote,li').forEach((block) => {
3318
3524
  if (!block.classList.contains('placeholder')) {
3319
3525
  block.setAttribute('dir', getBlockDirection(block, doc.body.dir as 'ltr' | 'rtl'))
3320
3526
  }
3321
3527
  })
3322
3528
 
3323
- // Apply direction to existing blockquotes and lists
3324
3529
  const blockElements = doc.body.querySelectorAll('blockquote, ul, ol')
3325
3530
  blockElements.forEach((element) => {
3326
3531
  const htmlElement = element as HTMLElement
@@ -3328,7 +3533,6 @@ async function initEditor() {
3328
3533
  htmlElement.dir = doc.body.dir || 'ltr'
3329
3534
  }
3330
3535
 
3331
- // Also apply direction to list items
3332
3536
  if (htmlElement.tagName === 'UL' || htmlElement.tagName === 'OL') {
3333
3537
  const listItems = htmlElement.querySelectorAll('li')
3334
3538
  listItems.forEach((li) => {
@@ -3340,40 +3544,33 @@ async function initEditor() {
3340
3544
  }
3341
3545
  })
3342
3546
 
3547
+ // Auto clear format for basic mode (convert top-level blocks to plain <p> without nesting <p> inside <p>)
3548
+ if (props.basic && normalizedModelValue.value) {
3549
+ const lines = Array.from(doc.body.childNodes)
3550
+ .map(node => (node.textContent || '').trim())
3551
+ .filter(Boolean)
3552
+ doc.body.innerHTML = ''
3553
+ if (lines.length === 0) {
3554
+ const p = doc.createElement('p')
3555
+ p.dir = doc.body.dir || 'ltr'
3556
+ p.innerHTML = '<br>'
3557
+ doc.body.appendChild(p)
3558
+ } else {
3559
+ lines.forEach((line) => {
3560
+ const p = doc.createElement('p')
3561
+ p.dir = getTextDirection(line, (doc.body.dir || 'ltr') as 'ltr' | 'rtl')
3562
+ p.textContent = line
3563
+ doc.body.appendChild(p)
3564
+ })
3565
+ }
3566
+ }
3567
+
3343
3568
  // Ensure editor.state.content is set to the current HTML content
3344
3569
  editor.state.content = doc.body.innerHTML
3345
3570
 
3346
3571
  editor.init(doc)
3347
3572
  useEditorKeyboard(doc, commands)
3348
3573
 
3349
- // Auto clear format for basic mode
3350
- if (props.basic && normalizedModelValue.value) {
3351
- setTimeout(() => {
3352
- // Clear all formatting by removing styles and replacing with plain text
3353
- const elements = doc.body.querySelectorAll('*')
3354
- elements.forEach((el) => {
3355
- if (el.tagName !== 'P' && el.tagName !== 'BR') {
3356
- const textContent = el.textContent || ''
3357
- if (textContent.trim()) {
3358
- const p = doc.createElement('p')
3359
- p.textContent = textContent
3360
- el.parentNode?.replaceChild(p, el)
3361
- } else {
3362
- el.remove()
3363
- }
3364
- } else if (el.tagName === 'P') {
3365
- // Remove all attributes from paragraphs
3366
- Array.from(el.attributes).forEach((attr) => {
3367
- if (attr.name !== 'dir') {
3368
- el.removeAttribute(attr.name)
3369
- }
3370
- })
3371
- }
3372
- })
3373
- updateContentWithHistory(doc)
3374
- }, 100)
3375
- }
3376
-
3377
3574
  // Add inline toolbar selection listener
3378
3575
  doc.addEventListener('selectionchange', () => {
3379
3576
  editor.updateState.selection()
@@ -3386,17 +3583,61 @@ async function initEditor() {
3386
3583
  setTimeout(() => { showInlineToolbarForSelection() }, 10)
3387
3584
  })
3388
3585
 
3389
- // Hide inline toolbar when clicking outside or when selection is lost
3586
+ // Hide inline toolbar & table context menu when clicking inside the iframe
3390
3587
  doc.addEventListener('click', (e) => {
3588
+ const target = e.target as HTMLElement
3589
+ if (target.closest('.table-inline-controls')) {
3590
+ return
3591
+ }
3592
+
3593
+ showTableContextMenu.value = false
3391
3594
  setTimeout(() => {
3392
3595
  const selection = doc.getSelection()
3393
3596
  if (!selection || selection.rangeCount === 0 || selection.getRangeAt(0).collapsed) {
3394
3597
  closeInlineToolbar()
3395
3598
  }
3396
3599
  }, 10)
3600
+
3601
+ const cell = target.closest('td, th') as HTMLTableCellElement | null
3602
+
3603
+ if (cell && cell.closest('table')) {
3604
+ const table = cell.closest('table')!
3605
+ if (e.shiftKey && contextMenuCell.value && contextMenuCell.value.closest('table') === table) {
3606
+ // Rectangular Shift+Click cell selection
3607
+ const map = buildTableGrid(table)
3608
+ const anchorCoords = findCellCoords(map, contextMenuCell.value)
3609
+ const targetCoords = findCellCoords(map, cell)
3610
+ doc.querySelectorAll('.table-cell-selected').forEach(c => c.classList.remove('table-cell-selected'))
3611
+ if (anchorCoords && targetCoords) {
3612
+ const minR = Math.min(anchorCoords.row, targetCoords.row)
3613
+ const maxR = Math.max(anchorCoords.row + anchorCoords.rowspan - 1, targetCoords.row + targetCoords.rowspan - 1)
3614
+ const minC = Math.min(anchorCoords.col, targetCoords.col)
3615
+ const maxC = Math.max(anchorCoords.col + anchorCoords.colspan - 1, targetCoords.col + targetCoords.colspan - 1)
3616
+ for (let r = minR; r <= maxR; r++) {
3617
+ for (let c = minC; c <= maxC; c++) {
3618
+ map.grid[r]?.[c]?.cell.classList.add('table-cell-selected')
3619
+ }
3620
+ }
3621
+ } else {
3622
+ cell.classList.add('table-cell-selected')
3623
+ contextMenuCell.value = cell
3624
+ }
3625
+ } else {
3626
+ doc.querySelectorAll('.table-cell-selected').forEach(c => c.classList.remove('table-cell-selected'))
3627
+ cell.classList.add('table-cell-selected')
3628
+ contextMenuCell.value = cell
3629
+ }
3630
+ } else {
3631
+ doc.querySelectorAll('.table-cell-selected').forEach(c => c.classList.remove('table-cell-selected'))
3632
+ }
3397
3633
  })
3398
3634
 
3399
- // Also hide when pressing keyboard arrows or escape
3635
+ // Also hide inline toolbar on scroll or when pressing keyboard arrows or escape
3636
+ doc.addEventListener('scroll', () => {
3637
+ closeInlineToolbar()
3638
+ showTableContextMenu.value = false
3639
+ }, { passive: true })
3640
+
3400
3641
  doc.addEventListener('keydown', (e) => {
3401
3642
  if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Escape'].includes(e.key)) {
3402
3643
  setTimeout(() => {
@@ -3428,7 +3669,7 @@ async function initEditor() {
3428
3669
  }
3429
3670
  })
3430
3671
 
3431
- // Add table context menu listener
3672
+ // Add table context menu listener with viewport boundary clamping
3432
3673
  doc.addEventListener('contextmenu', (e) => {
3433
3674
  const target = e.target as HTMLElement
3434
3675
  const cell = target.closest('td, th') as HTMLTableCellElement
@@ -3437,49 +3678,35 @@ async function initEditor() {
3437
3678
  e.preventDefault()
3438
3679
  contextMenuCell.value = cell
3439
3680
 
3440
- // Get iframe position relative to viewport
3441
3681
  const iframeRect = iframe.value!.getBoundingClientRect()
3682
+ const menuWidth = 180
3683
+ const menuHeight = 280
3684
+ const rawX = iframeRect.left + e.clientX
3685
+ const rawY = iframeRect.top + e.clientY
3442
3686
  contextMenuPosition.value = {
3443
- x: iframeRect.left + e.clientX,
3444
- y: iframeRect.top + e.clientY
3687
+ x: Math.max(8, Math.min(window.innerWidth - menuWidth - 8, rawX)),
3688
+ y: Math.max(8, Math.min(window.innerHeight - menuHeight - 8, rawY))
3445
3689
  }
3446
3690
  showTableContextMenu.value = true
3447
3691
  }
3448
3692
  })
3449
3693
 
3450
- // Add click listener to highlight selected table cell
3451
- doc.addEventListener('click', (e) => {
3452
- // Remove previous highlights
3453
- doc.querySelectorAll('.table-cell-selected').forEach((cell) => {
3454
- cell.classList.remove('table-cell-selected')
3455
- })
3456
-
3457
- const target = e.target as HTMLElement
3458
- const cell = target.closest('td, th') as HTMLTableCellElement
3459
-
3460
- if (cell && cell.closest('table')) {
3461
- // Highlight the selected cell
3462
- cell.classList.add('table-cell-selected')
3463
- contextMenuCell.value = cell
3464
- }
3465
- })
3466
-
3467
3694
  // Setup auto-wrapping for typed content
3468
3695
  setupAutoWrapping(doc)
3469
3696
 
3470
3697
  // Setup table edit buttons
3471
3698
  setupTableEditButtons(doc)
3472
3699
 
3473
- // Update state.content after cleanup
3474
- updateContentWithHistory(doc)
3700
+ // Update state.content after cleanup (skip pushing initial setup into undoStack)
3701
+ updateContentWithHistory(doc, true)
3475
3702
 
3476
3703
  // If editor is empty, add an initial paragraph
3477
- if (!doc.body.innerHTML.trim() || !doc.body.querySelector('p,h1,h2,h3,h4,h5,h6,blockquote,ul,ol,table')) {
3704
+ if (!doc.body.innerHTML.trim() || !doc.body.querySelector('p,h1,h2,h3,h4,h5,h6,blockquote,ul,ol,table,figure')) {
3478
3705
  const p = doc.createElement('p')
3479
3706
  p.dir = doc.body.dir
3707
+ p.innerHTML = '<br>'
3480
3708
  doc.body.appendChild(p)
3481
3709
 
3482
- // Set cursor in the new paragraph
3483
3710
  const range = doc.createRange()
3484
3711
  range.selectNodeContents(p)
3485
3712
  range.collapse(true)
@@ -3489,7 +3716,7 @@ async function initEditor() {
3489
3716
  selection.addRange(range)
3490
3717
  }
3491
3718
 
3492
- updateContentWithHistory(doc)
3719
+ updateContentWithHistory(doc, true)
3493
3720
  }
3494
3721
 
3495
3722
  // Only focus if autofocus is explicitly set to true
@@ -3555,29 +3782,55 @@ watch([currentInputColor, currentTextColor, currentFontFamily, effectiveContentS
3555
3782
  }
3556
3783
  }, { flush: 'post' })
3557
3784
 
3558
- // Reset initialization state when content changes significantly.
3559
- // Compare incoming value against the CLEAN output (not raw DOM innerHTML) to avoid
3560
- // false positives caused by editor chrome (table wrappers, edit buttons, etc.)
3561
- watch(normalizedModelValue, (newValue, oldValue) => {
3562
- const cleanCurrent = editor.state.doc ? getCleanHTML(editor.state.doc ?? null) : ''
3563
- if (newValue !== cleanCurrent) {
3564
- if (!oldValue || Math.abs(newValue.length - oldValue.length) > 50) {
3565
- hasInitialized.value = false
3566
- editor.state.content = newValue
3567
- editor.updateState.content('html')
3568
- setTimeout(() => {
3569
- if (editor.state.doc) {
3570
- updateContentWithHistory(editor.state.doc)
3571
- setupTableEditButtons(editor.state.doc)
3572
- }
3573
- }, 100)
3785
+ // Synchronize external modelValue changes cleanly without hiding the iframe or dropping small (<50 char) updates
3786
+ watch(normalizedModelValue, (newValue) => {
3787
+ const doc = editor.state.doc
3788
+ if (!doc?.body) {
3789
+ editor.state.content = newValue
3790
+ return
3791
+ }
3792
+
3793
+ const cleanCurrent = getCleanHTML(doc)
3794
+ if (newValue === cleanCurrent) {
3795
+ return
3796
+ }
3797
+
3798
+ pushHistorySnapshot(editor.state, editor.state.content)
3799
+ if (!newValue.trim()) {
3800
+ if (props.placeholder) {
3801
+ const placeholderEl = doc.createElement('p')
3802
+ placeholderEl.className = 'placeholder'
3803
+ placeholderEl.textContent = props.placeholder
3804
+ doc.body.replaceChildren(placeholderEl)
3574
3805
  } else {
3575
- setTimeout(() => {
3576
- if (editor.state.doc) {
3577
- setupTableEditButtons(editor.state.doc)
3578
- }
3579
- }, 50)
3806
+ const direction = getCurrentDirection()
3807
+ doc.body.innerHTML = `<p dir="${direction}"><br></p>`
3808
+ }
3809
+ } else {
3810
+ doc.body.innerHTML = newValue
3811
+ }
3812
+
3813
+ detectAndSetDirection()
3814
+ setupTableEditButtons(doc)
3815
+ editor.state.content = doc.body.innerHTML
3816
+ })
3817
+
3818
+ // Clean HTML model for Split View (CodeEditor) so internal editor chrome is never exposed
3819
+ const splitViewHtml = computed({
3820
+ get: () => {
3821
+ void editor.state.content
3822
+ return editor.state.doc ? getCleanHTML(editor.state.doc) : editor.state.content
3823
+ },
3824
+ set: (val: string) => {
3825
+ const doc = editor.state.doc
3826
+ if (!doc?.body) {
3827
+ editor.state.content = val
3828
+ return
3580
3829
  }
3830
+ pushHistorySnapshot(editor.state, editor.state.content)
3831
+ doc.body.innerHTML = val
3832
+ setupTableEditButtons(doc)
3833
+ editor.state.content = doc.body.innerHTML
3581
3834
  }
3582
3835
  })
3583
3836
 
@@ -3589,36 +3842,23 @@ watch(() => editor.state.content, () => {
3589
3842
  // Watch table alignment changes and update the table immediately
3590
3843
  watch(() => tableForm.value.alignment, (newAlignment) => {
3591
3844
  if (pendingTableData?.existingTable) {
3592
- const table = pendingTableData.existingTable
3593
- if (newAlignment === 'center') {
3594
- table.style.marginLeft = 'auto'
3595
- table.style.marginRight = 'auto'
3596
- } else if (newAlignment === 'right') {
3597
- table.style.marginLeft = 'auto'
3598
- table.style.marginRight = '0'
3599
- } else {
3600
- table.style.marginLeft = '0'
3601
- table.style.marginRight = 'auto'
3602
- }
3603
-
3604
- // Update content with history
3845
+ applyTableAlignmentToDOM(pendingTableData.existingTable, newAlignment as 'left' | 'center' | 'right')
3605
3846
  if (editor.state.doc) {
3606
3847
  updateContentWithHistory(editor.state.doc)
3607
3848
  }
3608
3849
  }
3609
3850
  })
3610
3851
 
3611
- // Close context menu when clicking outside
3612
3852
  function handleGlobalClick() {
3613
3853
  showTableContextMenu.value = false
3614
3854
  }
3615
3855
 
3616
- // Add event listener when component mounts
3617
- if (typeof window !== 'undefined') {
3618
- window.addEventListener('click', handleGlobalClick)
3619
- }
3856
+ onMounted(() => {
3857
+ if (typeof window !== 'undefined') {
3858
+ window.addEventListener('click', handleGlobalClick)
3859
+ }
3860
+ })
3620
3861
 
3621
- // Cleanup event listener
3622
3862
  onBeforeUnmount(() => {
3623
3863
  if (typeof window !== 'undefined') {
3624
3864
  window.removeEventListener('click', handleGlobalClick)
@@ -3693,14 +3933,13 @@ const imgTransform = computed({
3693
3933
  >
3694
3934
  <iframe
3695
3935
  id="rich-text-iframe" ref="iframe" class="editableContent" :class="{ 'editor-ready': hasInitialized }" title="Editor" srcdoc=""
3696
- sandbox="allow-same-origin allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-presentation allow-scripts allow-top-navigation allow-top-navigation-by-user-activation"
3697
- @load="initEditor" @contextmenu="handleTableContextMenu"
3698
- />
3936
+ sandbox="allow-same-origin allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-presentation"
3937
+ @load="initEditor"
3938
+ />
3699
3939
  </div>
3700
3940
  <CodeEditor
3701
- v-if="editor.state.isSplitView" v-model="editor.state.content" language="html" wrap
3941
+ v-if="editor.state.isSplitView" v-model="splitViewHtml" language="html" wrap
3702
3942
  :height="editor.state.isFullscreen ? 'calc(100vh - 4rem)' : editorHeight"
3703
- @update:modelValue="editor.updateState.content('html')"
3704
3943
  />
3705
3944
  </div>
3706
3945
  <div v-if="debug" class="flex pt-05">
@@ -3748,92 +3987,18 @@ const imgTransform = computed({
3748
3987
  :class="{ active: editor.state.selectedStyles.has('underline') }"
3749
3988
  @click="runInlineAction('underline')"
3750
3989
  />
3751
- <span class="separator">|</span>
3990
+ <Divider vertical size="1.25rem" class="mx-0125" />
3752
3991
  <Btn thin flat icon="add_link" @click="runInlineAction('link')" />
3753
3992
  </div>
3754
3993
  </div>
3755
- <!-- Table Context Menu -->
3756
- <div
3757
- v-if="showTableContextMenu" class="table-context-menu" :style="{
3758
- position: 'fixed',
3759
- top: `${contextMenuPosition.y}px`,
3760
- left: `${contextMenuPosition.x}px`,
3761
- zIndex: 1001,
3762
- }"
3763
- >
3764
- <div class="context-menu-content" @click.stop>
3765
- <div class="menu-header">
3766
- <span>Table Actions</span>
3767
- <button class="close-btn" @click="showTableContextMenu = false">
3768
- ×
3769
- </button>
3770
- </div>
3771
- <div class="px-025">
3772
- <Btn
3773
- full-width align-txt="start" thin icon="settings"
3774
- class="context-menu-btn context-menu-btn--advanced" value="Advanced Settings"
3775
- @mousedown.stop @click.stop="openExistingTableAdvancedSettings"
3776
- />
3777
- <div class="context-menu-separator" />
3778
- <Btn
3779
- v-if="canMergeRight" full-width align-txt="start" thin flat icon="start"
3780
- class="context-menu-btn" @click="mergeCellRight(); showTableContextMenu = false"
3781
- >
3782
- Merge Right
3783
- </Btn>
3784
- <Btn
3785
- v-if="canMergeDown" full-width align-txt="start" thin flat icon="text_select_move_down"
3786
- class="context-menu-btn" @click="mergeCellDown(); showTableContextMenu = false"
3787
- >
3788
- Merge Down
3789
- </Btn>
3790
- <Btn
3791
- v-if="canSplit" full-width align-txt="start" thin flat icon="call_split"
3792
- class="context-menu-btn" @click="splitCell(); showTableContextMenu = false"
3793
- >
3794
- Split Cell
3795
- </Btn>
3796
- <div v-if="canMergeRight || canMergeDown || canSplit" class="context-menu-separator" />
3797
- <Btn
3798
- full-width align-txt="start" thin flat icon="add_row_above" class="context-menu-btn"
3799
- @click="insertRowAbove(); showTableContextMenu = false"
3800
- >
3801
- Insert Row Above
3802
- </Btn>
3803
- <Btn
3804
- full-width align-txt="start" thin flat icon="add_row_below" class="context-menu-btn"
3805
- @click="insertRowBelow(); showTableContextMenu = false"
3806
- >
3807
- Insert Row Below
3808
- </Btn>
3809
- <Btn
3810
- full-width align-txt="start" thin flat icon="remove" class="context-menu-btn"
3811
- @click="deleteRow(); showTableContextMenu = false"
3812
- >
3813
- Delete Row
3814
- </Btn>
3815
- <div class="context-menu-separator" />
3816
- <Btn
3817
- full-width align-txt="start" thin flat icon="add_column_left" class="context-menu-btn"
3818
- @click="insertColumnLeft(); showTableContextMenu = false"
3819
- >
3820
- Insert Column Left
3821
- </Btn>
3822
- <Btn
3823
- full-width align-txt="start" thin flat icon="add_column_right" class="context-menu-btn"
3824
- @click="insertColumnRight(); showTableContextMenu = false"
3825
- >
3826
- Insert Column Right
3827
- </Btn>
3828
- <Btn
3829
- full-width align-txt="start" thin flat icon="remove" class="context-menu-btn"
3830
- @click="deleteColumn(); showTableContextMenu = false"
3831
- >
3832
- Delete Column
3833
- </Btn>
3834
- </div>
3835
- </div>
3836
- </div>
3994
+ <!-- Table Context Menu (built on shared ContextMenu / Dropdown) -->
3995
+ <ContextMenu
3996
+ v-model:shown="showTableContextMenu"
3997
+ :anchor-point="contextMenuPosition"
3998
+ title="Table Actions"
3999
+ :items="tableContextMenuItems"
4000
+ popup-class="rich-text-light-theme"
4001
+ />
3837
4002
  </div>
3838
4003
  <!-- Link Modal -->
3839
4004
  <Dialog
@@ -4045,13 +4210,7 @@ const imgTransform = computed({
4045
4210
  <template #default>
4046
4211
  <div class="grid grid-wrap-4 m_grid-wrap-2 gap-col-1 table-editor testMe1">
4047
4212
  <!-- Structure Section -->
4048
- <div class="flex gap-05 white-space mt-0 grid-span-4 m_grid-span-2 pt-1 pb-05">
4049
- <div class="line" />
4050
- <p class="label grid-span-4 m_grid-span-2">
4051
- Table Structure
4052
- </p>
4053
- <div class="line" />
4054
- </div>
4213
+ <Divider label="Table Structure" class="grid-span-4 m_grid-span-2 pt-05 pb-025" />
4055
4214
  <NumberInput v-model="tableForm.rows" :min="1" :max="20" label="Rows" />
4056
4215
  <NumberInput v-model="tableForm.cols" :min="1" :max="10" label="Columns" />
4057
4216
  <div class="grid-span-4 m_grid-span-2">
@@ -4071,52 +4230,31 @@ const imgTransform = computed({
4071
4230
  </p>
4072
4231
  </div>
4073
4232
  <!-- Cell Text Alignment -->
4074
- <div class="grid-span-1">
4075
- <label class="label">Cell Text Alignment</label>
4076
- <div
4077
- class="flex gap-025 mt-025 radius-1 p-05 w-fit"
4078
- style="height: var(--bgl-input-height); background: var(--bgl-input-bg);"
4079
- >
4080
- <Btn
4081
- :class="{ activeBtn: tableForm.alignment === 'left' }" flat thin icon="format_align_left"
4082
- title="Align Left" @click="tableForm.alignment = 'left'"
4083
- />
4084
- <Btn
4085
- :class="{ activeBtn: tableForm.alignment === 'center' }" flat thin
4086
- icon="format_align_center" title="Align Center" @click="tableForm.alignment = 'center'"
4087
- />
4088
- <Btn
4089
- :class="{ activeBtn: tableForm.alignment === 'right' }" flat thin icon="format_align_right"
4090
- title="Align Right" @click="tableForm.alignment = 'right'"
4091
- />
4092
- </div>
4093
- </div>
4233
+ <SelectBtn
4234
+ v-model="tableForm.alignment"
4235
+ label="Cell Text Alignment"
4236
+ class="grid-span-1"
4237
+ icon-only
4238
+ :options="[
4239
+ { value: 'left', label: 'Align Left', icon: 'format_align_left' },
4240
+ { value: 'center', label: 'Align Center', icon: 'format_align_center' },
4241
+ { value: 'right', label: 'Align Right', icon: 'format_align_right' },
4242
+ ]"
4243
+ />
4094
4244
 
4095
4245
  <!-- Text Direction -->
4096
- <div class="grid-span-1">
4097
- <label class="label">Text Direction</label>
4098
- <div
4099
- class="flex gap-025 mt-025 radius-1 p-025 w-fit"
4100
- style="height: var(--bgl-input-height); background: var(--bgl-input-bg);"
4101
- >
4102
- <Btn
4103
- :class="{ activeBtn: tableForm.direction === 'ltr' }" flat thin value="LTR"
4104
- title="Left to Right" @click="tableForm.direction = 'ltr'"
4105
- />
4106
- <Btn
4107
- :class="{ activeBtn: tableForm.direction === 'rtl' }" flat thin value="RTL"
4108
- title="Right to Left" @click="tableForm.direction = 'rtl'"
4109
- />
4110
- </div>
4111
- </div>
4246
+ <SelectBtn
4247
+ v-model="tableForm.direction"
4248
+ label="Text Direction"
4249
+ class="grid-span-1"
4250
+ :options="[
4251
+ { value: 'ltr', label: 'LTR' },
4252
+ { value: 'rtl', label: 'RTL' },
4253
+ ]"
4254
+ />
4255
+
4112
4256
  <!-- Style Section -->
4113
- <div class="flex gap-05 white-space mt-0 grid-span-4 m_grid-span-2 pt-1 pb-05">
4114
- <div class="line" />
4115
- <p class="label grid-span-4 m_grid-span-2">
4116
- Table Style
4117
- </p>
4118
- <div class="line" />
4119
- </div>
4257
+ <Divider label="Table Style" class="grid-span-4 m_grid-span-2 pt-05 pb-025" />
4120
4258
  <NumberInput v-model="tableForm.width" label="Width (%)" :min="25" :max="100" />
4121
4259
  <NumberInput v-model="tableForm.cellPadding" label="Cell Padding (px)" :min="2" :max="20" />
4122
4260
  <NumberInput v-model="tableForm.borderWidth" label="Border Width (px)" :min="0" :max="5" />
@@ -4127,26 +4265,14 @@ const imgTransform = computed({
4127
4265
  />
4128
4266
 
4129
4267
  <!-- Cell Colors -->
4130
- <div class="flex gap-05 white-space mt-0 grid-span-4 m_grid-span-2 pt-1 pb-05">
4131
- <div class="line" />
4132
- <p class="label grid-span-4 m_grid-span-2">
4133
- Cell Colors:
4134
- </p>
4135
- <div class="line" />
4136
- </div>
4268
+ <Divider label="Cell Colors" class="grid-span-4 m_grid-span-2 pt-05 pb-025" />
4137
4269
  <ColorInput
4138
4270
  v-model="tableForm.cellBgColor" class="grid-span-2 m_grid-span-1"
4139
4271
  label="Background Color"
4140
4272
  />
4141
4273
  <ColorInput v-model="tableForm.cellTextColor" class="grid-span-2 m_grid-span-1" label="Text Color" />
4142
4274
  <!-- Alternating Rows -->
4143
- <div class="flex gap-05 white-space mt-0 grid-span-4 m_grid-span-2 pt-1 pb-05">
4144
- <div class="line" />
4145
- <p class="label grid-span-4 m_grid-span-2">
4146
- Row Styles:
4147
- </p>
4148
- <div class="line" />
4149
- </div>
4275
+ <Divider label="Row Styles" class="grid-span-4 m_grid-span-2 pt-05 pb-025" />
4150
4276
  <CheckInput v-model="tableForm.showHeaders" label="Show header row" class="grid-span-4 m_grid-span-2" />
4151
4277
  <!-- Header Colors -->
4152
4278
  <div
@@ -4181,14 +4307,63 @@ const imgTransform = computed({
4181
4307
  </div>
4182
4308
  </div>
4183
4309
  <!-- Table Preview -->
4184
- <div class="flex gap-05 white-space mt-0 grid-span-4 m_grid-span-2 pt-1 pb-05">
4185
- <div class="line" />
4186
- <p class="label grid-span-4 m_grid-span-2">
4187
- Preview
4188
- </p>
4189
- <div class="line" />
4310
+ <Divider label="Preview" class="grid-span-4 m_grid-span-2 pt-05 pb-025" />
4311
+ <div style="zoom: 0.8;" class="opacity-7 user-select-none pointer-events-none">
4312
+ <table
4313
+ :dir="tableForm.direction"
4314
+ :style="{
4315
+ width: `${tableForm.width}%`,
4316
+ borderCollapse: 'collapse',
4317
+ marginBottom: '1rem',
4318
+ border: `${tableForm.borderWidth}px solid ${tableForm.borderColor}`,
4319
+ tableLayout: tableForm.fixedLayout ? 'fixed' : 'auto',
4320
+ }"
4321
+ >
4322
+ <colgroup>
4323
+ <col
4324
+ v-for="(colWidth, colIdx) in tableForm.columnWidths.slice(0, tableForm.cols)"
4325
+ :key="`col-${colIdx}`"
4326
+ :style="{ width: `${colWidth}%` }"
4327
+ >
4328
+ </colgroup>
4329
+ <thead v-if="tableForm.showHeaders">
4330
+ <tr>
4331
+ <th
4332
+ v-for="colIdx in tableForm.cols"
4333
+ :key="`th-${colIdx}`"
4334
+ :style="{
4335
+ padding: `${tableForm.cellPadding}px`,
4336
+ border: `${tableForm.borderWidth}px solid ${tableForm.borderColor}`,
4337
+ backgroundColor: tableForm.headerBgColor,
4338
+ color: tableForm.headerTextColor,
4339
+ textAlign: tableForm.alignment as any,
4340
+ width: tableForm.fixedLayout ? `${tableForm.columnWidths[colIdx - 1] ?? (100 / tableForm.cols)}%` : undefined,
4341
+ }"
4342
+ >
4343
+ Header {{ colIdx }}
4344
+ </th>
4345
+ </tr>
4346
+ </thead>
4347
+ <tbody>
4348
+ <tr v-for="rowIdx in tableForm.rows" :key="`tr-${rowIdx}`">
4349
+ <td
4350
+ v-for="colIdx in tableForm.cols"
4351
+ :key="`td-${rowIdx}-${colIdx}`"
4352
+ :style="{
4353
+ padding: `${tableForm.cellPadding}px`,
4354
+ border: `${tableForm.borderWidth}px solid ${tableForm.borderColor}`,
4355
+ textAlign: tableForm.alignment as any,
4356
+ width: tableForm.fixedLayout ? `${tableForm.columnWidths[colIdx - 1] ?? (100 / tableForm.cols)}%` : undefined,
4357
+ backgroundColor: tableForm.alternateRows && (rowIdx - 1) % 2 === 1 ? tableForm.alternateRowBgColor : tableForm.cellBgColor,
4358
+ color: tableForm.alternateRows && (rowIdx - 1) % 2 === 1 ? tableForm.alternateRowTextColor : tableForm.cellTextColor,
4359
+ }"
4360
+ >
4361
+ Cell {{ rowIdx }},{{ colIdx }}
4362
+ </td>
4363
+ </tr>
4364
+ </tbody>
4365
+ </table>
4190
4366
  </div>
4191
- <div style="zoom: 0.8;" class="opacity-7 user-select-none pointer-events-none" v-html="tablePreviewHtml" />
4192
4367
  </template>
4193
4368
 
4194
4369
  <template #footer>
@@ -4460,196 +4635,6 @@ background: var(--bgl-primary);
4460
4635
  color: white;
4461
4636
  }
4462
4637
 
4463
- .inline-toolbar-content .separator {
4464
- color: var(--bgl-border-color, #dddddd);
4465
- margin: 0 0.25rem;
4466
- opacity: 0.5;
4467
- }
4468
-
4469
- /* Table Context Menu */
4470
- .table-context-menu {
4471
- pointer-events: auto;
4472
- }
4473
-
4474
- .context-menu-content {
4475
- background: var(--bgl-popup-bg, white);
4476
- color: var(--bgl-popup-text);
4477
- border: 1px solid var(--bgl-border-color, #dddddd);
4478
- border-radius: var(--bgl-btn-border-radius) !important;
4479
- box-shadow: 0 2px 8px var(--bgl-shadow, rgba(0, 0, 0, 0.15));
4480
- padding: 0;
4481
- min-width: 140px;
4482
- max-width: 180px;
4483
- overflow: hidden;
4484
- --bgl-input-font-size: 12px;
4485
- }
4486
-
4487
- .menu-header {
4488
- display: flex;
4489
- justify-content: space-between;
4490
- align-items: center;
4491
- padding: 8px 12px;
4492
- background-color: var(--bg-light, #f8f9fa);
4493
- border-bottom: 1px solid var(--bgl-border-color, #dddddd);
4494
- font-size: 12px;
4495
- font-weight: 500;
4496
- color: var(--text-secondary, #666);
4497
- margin-bottom: 5px;
4498
- }
4499
-
4500
- .close-btn {
4501
- background: none;
4502
- border: none;
4503
- font-size: 16px;
4504
- cursor: pointer;
4505
- padding: 0;
4506
- width: 20px;
4507
- height: 20px;
4508
- display: flex;
4509
- align-items: center;
4510
- justify-content: center;
4511
- border-radius: 3px;
4512
- color: var(--text-secondary, #666);
4513
- transition: all 0.2s;
4514
- }
4515
-
4516
- .close-btn:hover {
4517
- background-color: var(--hover-bg, #e9ecef);
4518
- color: var(--text-primary, #333);
4519
- }
4520
-
4521
- .context-menu-content>.btn {
4522
- margin: 2px 4px;
4523
- }
4524
-
4525
- .context-menu-item {
4526
- padding: 8px 16px;
4527
- cursor: pointer;
4528
- transition: background-color 0.2s;
4529
- font-size: 14px;
4530
- }
4531
-
4532
- .context-menu-item:hover {
4533
- background-color: var(--hover-bg, #f5f5f5);
4534
- }
4535
-
4536
- .context-menu-separator {
4537
- height: 1px;
4538
- background-color: var(--bgl-border-color, #dddddd);
4539
- margin: 4px 0;
4540
- }
4541
-
4542
- .context-menu-btn:hover {
4543
- background-color: var(--hover-bg, #f5f5f5) !important;
4544
- }
4545
-
4546
- .context-menu-btn--advanced,
4547
- .context-menu-btn--advanced:hover,
4548
- .context-menu-btn--advanced:focus-visible {
4549
- background: #000 !important;
4550
- border: 1px solid #fff !important;
4551
- color: #fff !important;
4552
- }
4553
-
4554
- .context-menu-btn--advanced:hover,
4555
- .context-menu-btn--advanced:focus-visible {
4556
- background: #1f1f1f !important;
4557
- }
4558
-
4559
- /* Table Cell Selection */
4560
- .table-cell-selected {
4561
- background-color: rgba(0, 123, 255, 0.1) !important;
4562
- border: 2px solid #007bff !important;
4563
- position: relative;
4564
- }
4565
-
4566
- .table-cell-selected::after {
4567
- content: '';
4568
- position: absolute;
4569
- top: -2px;
4570
- left: -2px;
4571
- right: -2px;
4572
- bottom: -2px;
4573
- border: 2px solid #007bff;
4574
- pointer-events: none;
4575
- z-index: 1;
4576
- }
4577
-
4578
- /* Table Modal Button Active States */
4579
- .activeBtn {
4580
- background: var(--bgl-primary) !important;
4581
- color: white !important;
4582
- border-color: var(--bgl-primary) !important;
4583
- }
4584
-
4585
- /* Table Edit Button Protection */
4586
- .table-edit-btn {
4587
- position: absolute !important;
4588
- top: 5px !important;
4589
- right: 5px !important;
4590
- background: rgba(0, 0, 0, 0.7) !important;
4591
- color: white !important;
4592
- -webkit-text-fill-color: white !important;
4593
- padding: 0 !important;
4594
- border: 1px solid white !important;
4595
- border-radius: 4px !important;
4596
- font-size: 12px !important;
4597
- cursor: pointer !important;
4598
- opacity: 0 !important;
4599
- visibility: hidden !important;
4600
- z-index: 100 !important;
4601
- pointer-events: auto !important;
4602
- user-select: none !important;
4603
- -webkit-user-select: none !important;
4604
- -moz-user-select: none !important;
4605
- -ms-user-select: none !important;
4606
- outline: none !important;
4607
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2) !important;
4608
- white-space: nowrap !important;
4609
- overflow: hidden !important;
4610
- text-overflow: clip !important;
4611
- min-width: unset !important;
4612
- max-width: unset !important;
4613
- width: 28px !important;
4614
- height: 28px !important;
4615
- line-height: 26px !important;
4616
- font-family: inherit !important;
4617
- font-weight: normal !important;
4618
- text-align: center !important;
4619
- vertical-align: baseline !important;
4620
- text-decoration: none !important;
4621
- text-transform: none !important;
4622
- letter-spacing: normal !important;
4623
- word-spacing: normal !important;
4624
- text-indent: 0 !important;
4625
- text-shadow: none !important;
4626
- direction: ltr !important;
4627
- unicode-bidi: normal !important;
4628
- transition: opacity 0.15s ease, visibility 0.15s ease, background 0.15s ease !important;
4629
- }
4630
-
4631
- .table-wrapper:hover > .table-edit-btn,
4632
- .table-wrapper:focus-within > .table-edit-btn,
4633
- .table-edit-btn:hover,
4634
- .table-edit-btn:focus {
4635
- opacity: 1 !important;
4636
- visibility: visible !important;
4637
- }
4638
-
4639
- .table-edit-btn:hover {
4640
- background: rgba(0, 0, 0, 0.9) !important;
4641
- }
4642
-
4643
- /* Prevent any content insertion into table edit buttons */
4644
- .table-edit-btn * {
4645
- display: none !important;
4646
- }
4647
-
4648
- .table-edit-btn::before,
4649
- .table-edit-btn::after {
4650
- content: none !important;
4651
- }
4652
-
4653
4638
  /* Editor Tooltip */
4654
4639
  .editor-tooltip {
4655
4640
  position: fixed;