ep_data_tables 0.0.2 → 0.0.4

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.
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ // Ensure settings.toolbar exists early to avoid load-order error with ep_font_color
4
+ const settings = require('ep_etherpad-lite/node/utils/Settings');
5
+ if (!settings.toolbar) settings.toolbar = {};
6
+
3
7
  // Using console.log for server-side logging, similar to other Etherpad server-side files.
4
8
  const log = (...m) => console.log('[ep_data_tables:collectContentPre_SERVER]', ...m);
5
9
 
package/ep.json CHANGED
@@ -10,8 +10,6 @@
10
10
  },
11
11
  "client_hooks": {
12
12
  "collectContentPre" : "ep_data_tables/static/js/client_hooks:collectContentPre",
13
- "collectContentLineBreak": "ep_data_tables/static/js/collector_hooks:collectContentLineBreak",
14
- "collectContentLineText" : "ep_data_tables/static/js/collector_hooks:collectContentLineText",
15
13
  "aceKeyEvent" : "ep_data_tables/static/js/client_hooks:aceKeyEvent",
16
14
  "aceStartLineAndCharForPoint" : "ep_data_tables/static/js/client_hooks:aceStartLineAndCharForPoint",
17
15
  "aceEndLineAndCharForPoint" : "ep_data_tables/static/js/client_hooks:aceEndLineAndCharForPoint",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_data_tables",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "BETA - etherpad tables plugin, compatible with other character/line based styling and other features",
5
5
  "author": {
6
6
  "name": "DCastelone",
@@ -60,6 +60,21 @@ let resizeOriginalWidths = [];
60
60
  let resizeTableMetadata = null;
61
61
  let resizeLineNum = -1;
62
62
  let resizeOverlay = null; // Visual overlay element
63
+ // Android Chrome composition handling state
64
+ let suppressNextBeforeInputInsertTextOnce = false;
65
+ let isAndroidChromeComposition = false;
66
+ let handledCurrentComposition = false;
67
+ // Suppress all beforeinput insertText events during an Android Chrome IME composition
68
+ let suppressBeforeInputInsertTextDuringComposition = false;
69
+ // Helper to detect Android Chromium-family browsers (exclude iOS and Firefox)
70
+ function isAndroidChromiumUA() {
71
+ const ua = (navigator.userAgent || '').toLowerCase();
72
+ const isAndroid = ua.includes('android');
73
+ const isIOS = ua.includes('iphone') || ua.includes('ipad') || ua.includes('ipod') || ua.includes('crios');
74
+ const isFirefox = ua.includes('firefox');
75
+ const isChromiumFamily = ua.includes('chrome') || ua.includes('edg') || ua.includes('opr') || ua.includes('samsungbrowser') || ua.includes('vivaldi') || ua.includes('brave');
76
+ return isAndroid && !isIOS && !isFirefox && isChromiumFamily;
77
+ }
63
78
 
64
79
  // ─────────────────── Reusable Helper Functions ───────────────────
65
80
 
@@ -472,6 +487,12 @@ exports.collectContentPre = (hook, ctx) => {
472
487
  // the span would be mistaken for a real cell boundary.
473
488
  const hiddenDelimRegexPrimary = /<span class="ep-data_tables-delim"[^>]*>.*?<\/span>/ig;
474
489
  segmentHTML = segmentHTML.replace(hiddenDelimRegexPrimary, '');
490
+ // Remove caret-anchor spans (invisible, non-semantic)
491
+ const caretAnchorRegex = /<span class="ep-data_tables-caret-anchor"[^>]*><\/span>/ig;
492
+ segmentHTML = segmentHTML.replace(caretAnchorRegex, '');
493
+ // If, after stripping tags/entities, the content is empty, serialize as empty string
494
+ const textCheck = segmentHTML.replace(/<[^>]*>/g, '').replace(/&nbsp;/ig, ' ').trim();
495
+ if (textCheck === '') segmentHTML = '';
475
496
 
476
497
  const hidden = index === 0 ? '' :
477
498
  /* keep the char in the DOM but make it visually disappear and non-editable */
@@ -482,7 +503,7 @@ exports.collectContentPre = (hook, ctx) => {
482
503
 
483
504
  if (cellHTMLSegments.length !== existingMetadata.cols) {
484
505
  // log(`${funcName}: WARNING Line ${lineNum}: Reconstructed cell count (${cellHTMLSegments.length}) does not match metadata cols (${existingMetadata.cols}). Padding/truncating.`);
485
- while (cellHTMLSegments.length < existingMetadata.cols) cellHTMLSegments.push('&nbsp;');
506
+ while (cellHTMLSegments.length < existingMetadata.cols) cellHTMLSegments.push('');
486
507
  if (cellHTMLSegments.length > existingMetadata.cols) cellHTMLSegments.length = existingMetadata.cols;
487
508
  }
488
509
 
@@ -520,15 +541,21 @@ exports.collectContentPre = (hook, ctx) => {
520
541
  const resizeHandleRegex = /<div class="ep-data_tables-resize-handle"[^>]*><\/div>/ig;
521
542
  segmentHTML = segmentHTML.replace(resizeHandleRegex, '');
522
543
  if (index > 0) {
523
- const hiddenDelimRegex = new RegExp(`^<span class="ep-data_tables-delim" contenteditable="false">${DELIMITER}(<\\/span>)?<\\/span>`, 'i');
544
+ const hiddenDelimRegex = new RegExp('^<span class="ep-data_tables-delim" contenteditable="false">' + DELIMITER + '(<\\/span>)?<\\/span>', 'i');
524
545
  segmentHTML = segmentHTML.replace(hiddenDelimRegex, '');
525
546
  }
547
+ // Remove caret-anchor spans (invisible, non-semantic)
548
+ const caretAnchorRegex = /<span class="ep-data_tables-caret-anchor"[^>]*><\/span>/ig;
549
+ segmentHTML = segmentHTML.replace(caretAnchorRegex, '');
550
+ // If, after stripping tags/entities, the content is empty, serialize as empty string
551
+ const textCheck = segmentHTML.replace(/<[^>]*>/g, '').replace(/&nbsp;/ig, ' ').trim();
552
+ if (textCheck === '') segmentHTML = '';
526
553
  return segmentHTML;
527
554
  });
528
555
 
529
556
  if (cellHTMLSegments.length !== domCols) {
530
557
  // log(`${funcName}: WARNING Line ${lineNum} (Fallback): Reconstructed cell count (${cellHTMLSegments.length}) does not match DOM cols (${domCols}).`);
531
- while(cellHTMLSegments.length < domCols) cellHTMLSegments.push('&nbsp;');
558
+ while(cellHTMLSegments.length < domCols) cellHTMLSegments.push('');
532
559
  if(cellHTMLSegments.length > domCols) cellHTMLSegments.length = domCols;
533
560
  }
534
561
 
@@ -672,24 +699,41 @@ function buildTableFromDelimitedHTML(metadata, innerHTMLSegments) {
672
699
  // Basic styling - can be moved to CSS later
673
700
  const tdStyle = `padding: 5px 7px; word-wrap:break-word; vertical-align: top; border: 1px solid #000; position: relative;`; // Added position: relative
674
701
 
702
+ // Precompute encoded tbljson class so empty cells can carry the same marker
703
+ let encodedTbljsonClass = '';
704
+ try {
705
+ encodedTbljsonClass = `tbljson-${enc(JSON.stringify(metadata))}`;
706
+ } catch (_) { encodedTbljsonClass = ''; }
707
+
675
708
  // Map the HTML segments directly into TD elements with column widths
676
709
  const cellsHtml = innerHTMLSegments.map((segment, index) => {
677
710
  // Build the hidden delimiter *inside* the first author span so the caret
678
- // cannot sit between delimiter and text.
679
- let modifiedSegment = segment || '&nbsp;';
711
+ // cannot sit between delimiter and text. For empty cells, synthesize a span
712
+ // that carries tbljson and tblCell-N so caret anchoring remains stable.
713
+ const textOnly = (segment || '').replace(/<[^>]*>/g, '').replace(/&nbsp;/ig, ' ').trim();
714
+ let modifiedSegment = segment || '';
715
+ const isEmpty = !segment || textOnly === '';
716
+ if (isEmpty) {
717
+ const cellClass = encodedTbljsonClass ? `${encodedTbljsonClass} tblCell-${index}` : `tblCell-${index}`;
718
+ modifiedSegment = `<span class="${cellClass}">&nbsp;</span>`;
719
+ }
680
720
  if (index > 0) {
681
721
  const delimSpan = `<span class="ep-data_tables-delim" contenteditable="false">${HIDDEN_DELIM}</span>`;
682
722
  // If the rendered segment already starts with a <span …> (which will be
683
723
  // the usual author-colour wrapper) inject the delimiter right after that
684
724
  // opening tag; otherwise just prefix it.
685
725
  modifiedSegment = modifiedSegment.replace(/^(<span[^>]*>)/i, `$1${delimSpan}`);
686
- if (modifiedSegment === segment) modifiedSegment = `${delimSpan}${modifiedSegment}`;
726
+ if (!/^<span[^>]*>/i.test(modifiedSegment)) modifiedSegment = `${delimSpan}${modifiedSegment}`;
687
727
  }
688
728
 
689
- // --- NEW: Always embed the invisible caret-anchor as *last* child *within* the first author span ---
729
+ // --- NEW: Always embed the invisible caret-anchor as *last* child *within* the first span ---
690
730
  const caretAnchorSpan = '<span class="ep-data_tables-caret-anchor" contenteditable="false"></span>';
691
731
  const anchorInjected = modifiedSegment.replace(/<\/span>\s*$/i, `${caretAnchorSpan}</span>`);
692
- modifiedSegment = (anchorInjected !== modifiedSegment) ? anchorInjected : `${modifiedSegment}${caretAnchorSpan}`;
732
+ modifiedSegment = (anchorInjected !== modifiedSegment)
733
+ ? anchorInjected
734
+ : (isEmpty
735
+ ? `<span class="${encodedTbljsonClass ? `${encodedTbljsonClass} ` : ''}tblCell-${index}">${modifiedSegment}${caretAnchorSpan}</span>`
736
+ : `${modifiedSegment}${caretAnchorSpan}`);
693
737
 
694
738
  // Width & other decorations remain unchanged
695
739
  const widthPercent = columnWidths[index] || (100 / numCols);
@@ -907,7 +951,7 @@ exports.acePostWriteDomLineHTML = function (hook_name, args, cb) {
907
951
  const delimiterCount = (delimitedTextFromLine || '').split(DELIMITER).length - 1;
908
952
  // log(`${logPrefix} NodeID#${nodeId}: Delimiter '${DELIMITER}' count in innerHTML: ${delimiterCount}`);
909
953
  // log(`${logPrefix} NodeID#${nodeId}: Expected delimiters for ${rowMetadata.cols} columns: ${rowMetadata.cols - 1}`);
910
-
954
+
911
955
  // log all delimiter positions
912
956
  let pos = -1;
913
957
  const delimiterPositions = [];
@@ -922,7 +966,10 @@ exports.acePostWriteDomLineHTML = function (hook_name, args, cb) {
922
966
  // the embedded delimiter character they carry doesn't inflate or shrink
923
967
  // the segment count.
924
968
  const spanDelimRegex = new RegExp('<span class="ep-data_tables-delim"[^>]*>' + DELIMITER + '<\\/span>', 'ig');
925
- const sanitizedHTMLForSplit = (delimitedTextFromLine || '').replace(spanDelimRegex, '');
969
+ const sanitizedHTMLForSplit = (delimitedTextFromLine || '')
970
+ .replace(spanDelimRegex, '')
971
+ // strip caret anchors from raw line html before split
972
+ .replace(/<span class="ep-data_tables-caret-anchor"[^>]*><\/span>/ig, '');
926
973
  const htmlSegments = sanitizedHTMLForSplit.split(DELIMITER);
927
974
 
928
975
  // log(`${logPrefix} NodeID#${nodeId}: *** SEGMENT ANALYSIS ***`);
@@ -949,57 +996,83 @@ exports.acePostWriteDomLineHTML = function (hook_name, args, cb) {
949
996
  let finalHtmlSegments = htmlSegments;
950
997
 
951
998
  if (htmlSegments.length !== rowMetadata.cols) {
952
- // log(`${logPrefix} NodeID#${nodeId}: *** MISMATCH DETECTED ***`);
953
- // log(`${logPrefix} NodeID#${nodeId}: WARNING - Parsed segment count (${htmlSegments.length}) does not match metadata cols (${rowMetadata.cols}). Auto-reconstructing table structure.`);
954
- console.warn(`[ep_data_tables] ${funcName} NodeID#${nodeId}: Parsed segment count (${htmlSegments.length}) mismatch with metadata cols (${rowMetadata.cols}). Segments:`, htmlSegments);
955
-
956
- // *** ENHANCED DEBUG: Analyze why we have a mismatch ***
957
- // log(`${logPrefix} NodeID#${nodeId}: *** MISMATCH ANALYSIS ***`);
958
- // log(`${logPrefix} NodeID#${nodeId}: Expected columns: ${rowMetadata.cols}`);
959
- // log(`${logPrefix} NodeID#${nodeId}: Actual segments: ${htmlSegments.length}`);
960
- // log(`${logPrefix} NodeID#${nodeId}: Delimiter count found: ${delimiterCount}`);
961
- // log(`${logPrefix} NodeID#${nodeId}: Expected delimiter count: ${rowMetadata.cols - 1}`);
999
+ // log(`${logPrefix} NodeID#${nodeId}: *** MISMATCH DETECTED *** - Attempting reconstruction.`);
962
1000
 
963
1001
  // Check if this is an image selection issue
964
1002
  const hasImageSelected = delimitedTextFromLine.includes('currently-selected');
965
1003
  const hasImageContent = delimitedTextFromLine.includes('image:');
966
- // log(`${logPrefix} NodeID#${nodeId}: Has selected image: ${hasImageSelected}`);
967
- // log(`${logPrefix} NodeID#${nodeId}: Has image content: ${hasImageContent}`);
968
-
969
1004
  if (hasImageSelected) {
970
1005
  // log(`${logPrefix} NodeID#${nodeId}: *** POTENTIAL CAUSE: Image selection state may be affecting segment parsing ***`);
971
1006
  }
972
-
973
- // ENHANCED: Always reconstruct the correct structure based on metadata
974
- const reconstructedSegments = [];
975
-
976
- if (htmlSegments.length === 1 && rowMetadata.cols > 1) {
977
- // Single segment case - put all content in first column, empty remaining columns
978
- // log(`${logPrefix} NodeID#${nodeId}: Single segment detected, distributing content to first column of ${rowMetadata.cols} columns.`);
979
- reconstructedSegments.push(htmlSegments[0]);
980
- for (let i = 1; i < rowMetadata.cols; i++) {
981
- reconstructedSegments.push('&nbsp;');
982
- }
983
- } else if (htmlSegments.length > rowMetadata.cols) {
984
- // Too many segments - merge excess into last column
985
- // log(`${logPrefix} NodeID#${nodeId}: Too many segments (${htmlSegments.length}), merging excess into ${rowMetadata.cols} columns.`);
986
- for (let i = 0; i < rowMetadata.cols - 1; i++) {
987
- reconstructedSegments.push(htmlSegments[i] || '&nbsp;');
1007
+
1008
+ // First attempt: reconstruct using DOM spans that carry tblCell-N classes
1009
+ let usedClassReconstruction = false;
1010
+ try {
1011
+ const cols = Math.max(0, Number(rowMetadata.cols) || 0);
1012
+ const grouped = Array.from({ length: cols }, () => '');
1013
+ const candidates = Array.from(node.querySelectorAll('[class*="tblCell-"]'));
1014
+
1015
+ const classNum = (el) => {
1016
+ if (!el || !el.classList) return -1;
1017
+ for (const cls of el.classList) {
1018
+ const m = /^tblCell-(\d+)$/.exec(cls);
1019
+ if (m) return parseInt(m[1], 10);
1020
+ }
1021
+ return -1;
1022
+ };
1023
+ const hasAncestorWithSameCell = (el, n) => {
1024
+ let p = el?.parentElement;
1025
+ while (p) {
1026
+ if (p.classList && p.classList.contains(`tblCell-${n}`)) return true;
1027
+ p = p.parentElement;
1028
+ }
1029
+ return false;
1030
+ };
1031
+
1032
+ for (const el of candidates) {
1033
+ const n = classNum(el);
1034
+ if (n >= 0 && n < cols) {
1035
+ if (!hasAncestorWithSameCell(el, n)) {
1036
+ grouped[n] += el.outerHTML || '';
1037
+ }
1038
+ }
1039
+ }
1040
+ const usable = grouped.some(s => s && s.trim() !== '');
1041
+ if (usable) {
1042
+ finalHtmlSegments = grouped.map(s => (s && s.trim() !== '') ? s : '&nbsp;');
1043
+ usedClassReconstruction = true;
1044
+ console.warn(`[ep_data_tables] ${funcName} NodeID#${nodeId}: Reconstructed ${finalHtmlSegments.length} segments from tblCell-N classes.`);
1045
+ }
1046
+ } catch (e) {
1047
+ console.debug(`[ep_data_tables] ${funcName} NodeID#${nodeId}: Class-based reconstruction error; falling back.`, e);
988
1048
  }
989
- // Merge remaining segments into last column
1049
+
1050
+ // Fallback: reconstruct from string segments
1051
+ if (!usedClassReconstruction) {
1052
+ const reconstructedSegments = [];
1053
+ if (htmlSegments.length === 1 && rowMetadata.cols > 1) {
1054
+ reconstructedSegments.push(htmlSegments[0]);
1055
+ for (let i = 1; i < rowMetadata.cols; i++) {
1056
+ reconstructedSegments.push('&nbsp;');
1057
+ }
1058
+ } else if (htmlSegments.length > rowMetadata.cols) {
1059
+ for (let i = 0; i < rowMetadata.cols - 1; i++) {
1060
+ reconstructedSegments.push(htmlSegments[i] || '&nbsp;');
1061
+ }
990
1062
  const remainingSegments = htmlSegments.slice(rowMetadata.cols - 1);
991
1063
  reconstructedSegments.push(remainingSegments.join('|') || '&nbsp;');
992
- } else {
993
- // Too few segments - pad with empty columns
994
- // log(`${logPrefix} NodeID#${nodeId}: Too few segments (${htmlSegments.length}), padding to ${rowMetadata.cols} columns.`);
995
- for (let i = 0; i < rowMetadata.cols; i++) {
996
- reconstructedSegments.push(htmlSegments[i] || '&nbsp;');
1064
+ } else {
1065
+ for (let i = 0; i < rowMetadata.cols; i++) {
1066
+ reconstructedSegments.push(htmlSegments[i] || '&nbsp;');
997
1067
  }
1068
+ }
1069
+ finalHtmlSegments = reconstructedSegments;
1070
+ }
1071
+
1072
+ // Only warn if we still don't have the right number of segments
1073
+ if (finalHtmlSegments.length !== rowMetadata.cols) {
1074
+ console.warn(`[ep_data_tables] ${funcName} NodeID#${nodeId}: Could not reconstruct to expected ${rowMetadata.cols} segments. Got ${finalHtmlSegments.length}.`);
998
1075
  }
999
-
1000
- // log(`${logPrefix} NodeID#${nodeId}: Reconstructed ${reconstructedSegments.length} segments to match expected ${rowMetadata.cols} columns.`);
1001
- finalHtmlSegments = reconstructedSegments;
1002
-
1003
1076
  } else {
1004
1077
  // log(`${logPrefix} NodeID#${nodeId}: Segment count matches metadata cols (${rowMetadata.cols}). Using original segments.`);
1005
1078
  }
@@ -1533,12 +1606,12 @@ exports.aceKeyEvent = (h, ctx) => {
1533
1606
  // --- Check for Ctrl+X (Cut) key combination ---
1534
1607
  const isCutKey = (evt.ctrlKey || evt.metaKey) && (evt.key === 'x' || evt.key === 'X' || evt.keyCode === 88);
1535
1608
  if (isCutKey && hasSelection) {
1536
- // log(`${logPrefix} Ctrl+X (Cut) detected with selection. Letting cut event handler manage this.`);
1609
+ log(`${logPrefix} Ctrl+X (Cut) detected with selection. Letting cut event handler manage this.`);
1537
1610
  // Let the cut event handler handle this - we don't need to preventDefault here
1538
1611
  // as the cut event will handle the operation and prevent default
1539
1612
  return false; // Allow the cut event to be triggered
1540
1613
  } else if (isCutKey && !hasSelection) {
1541
- // log(`${logPrefix} Ctrl+X (Cut) detected but no selection. Allowing default.`);
1614
+ log(`${logPrefix} Ctrl+X (Cut) detected but no selection. Allowing default.`);
1542
1615
  return false; // Allow default - nothing to cut
1543
1616
  }
1544
1617
 
@@ -1892,38 +1965,37 @@ exports.aceInitialized = (h, ctx) => {
1892
1965
  }
1893
1966
 
1894
1967
  // *** CUT EVENT LISTENER ***
1895
- // log(`${callWithAceLogPrefix} Attaching cut event listener to $inner (inner iframe body).`);
1968
+ log(`${callWithAceLogPrefix} Attaching cut event listener to $inner (inner iframe body).`);
1896
1969
  $inner.on('cut', (evt) => {
1897
1970
  const cutLogPrefix = '[ep_data_tables:cutHandler]';
1898
- // log(`${cutLogPrefix} CUT EVENT TRIGGERED. Event object:`, evt);
1971
+ console.log(`${cutLogPrefix} CUT EVENT TRIGGERED. Event object:`, evt);
1899
1972
 
1900
- // log(`${cutLogPrefix} Getting current editor representation (rep).`);
1973
+ console.log(`${cutLogPrefix} Getting current editor representation (rep).`);
1901
1974
  const rep = ed.ace_getRep();
1902
1975
  if (!rep || !rep.selStart) {
1903
- // log(`${cutLogPrefix} WARNING: Could not get representation or selection. Allowing default cut.`);
1976
+ console.warn(`${cutLogPrefix} WARNING: Could not get representation or selection. Allowing default cut.`);
1904
1977
  console.warn(`${cutLogPrefix} Could not get rep or selStart.`);
1905
1978
  return; // Allow default
1906
1979
  }
1907
- // log(`${cutLogPrefix} Rep obtained. selStart:`, rep.selStart, `selEnd:`, rep.selEnd);
1980
+ console.log(`${cutLogPrefix} Rep obtained. selStart:`, rep.selStart, `selEnd:`, rep.selEnd);
1908
1981
  const selStart = rep.selStart;
1909
1982
  const selEnd = rep.selEnd;
1910
1983
  const lineNum = selStart[0];
1911
- // log(`${cutLogPrefix} Current line number: ${lineNum}. Column start: ${selStart[1]}, Column end: ${selEnd[1]}.`);
1912
-
1913
- // Check if there's actually a selection to cut
1914
- if (selStart[0] === selEnd[0] && selStart[1] === selEnd[1]) {
1915
- // log(`${cutLogPrefix} No selection to cut. Allowing default cut.`);
1916
- return; // Allow default - nothing to cut
1984
+ console.log(`${cutLogPrefix} Current line number: ${lineNum}. Column start: ${selStart[1]}, Column end: ${selEnd[1]}.`);
1985
+ // Determine if there is a selection in the editor representation
1986
+ const hasSelectionInRep = !(selStart[0] === selEnd[0] && selStart[1] === selEnd[1]);
1987
+ if (!hasSelectionInRep) {
1988
+ console.log(`${cutLogPrefix} No selection detected in rep; deferring decision until table-line check.`);
1917
1989
  }
1918
1990
 
1919
1991
  // Check if selection spans multiple lines
1920
1992
  if (selStart[0] !== selEnd[0]) {
1921
- // log(`${cutLogPrefix} WARNING: Selection spans multiple lines. Preventing cut to protect table structure.`);
1993
+ console.warn(`${cutLogPrefix} WARNING: Selection spans multiple lines. Preventing cut to protect table structure.`);
1922
1994
  evt.preventDefault();
1923
1995
  return;
1924
1996
  }
1925
1997
 
1926
- // log(`${cutLogPrefix} Checking if line ${lineNum} is a table line by fetching '${ATTR_TABLE_JSON}' attribute.`);
1998
+ console.log(`${cutLogPrefix} Checking if line ${lineNum} is a table line by fetching '${ATTR_TABLE_JSON}' attribute.`);
1927
1999
  let lineAttrString = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
1928
2000
  let tableMetadata = null;
1929
2001
 
@@ -1940,11 +2012,18 @@ exports.aceInitialized = (h, ctx) => {
1940
2012
  }
1941
2013
 
1942
2014
  if (!tableMetadata || typeof tableMetadata.cols !== 'number' || typeof tableMetadata.tblId === 'undefined' || typeof tableMetadata.row === 'undefined') {
1943
- // log(`${cutLogPrefix} Line ${lineNum} is NOT a recognised table line. Allowing default cut.`);
2015
+ console.log(`${cutLogPrefix} Line ${lineNum} is NOT a recognised table line. Allowing default cut.`);
1944
2016
  return; // Not a table line
1945
2017
  }
1946
2018
 
1947
- // log(`${cutLogPrefix} Line ${lineNum} IS a table line. Metadata:`, tableMetadata);
2019
+ console.log(`${cutLogPrefix} Line ${lineNum} IS a table line. Metadata:`, tableMetadata);
2020
+
2021
+ // If inside a table line but the rep shows no selection, prevent default to protect structure
2022
+ if (!hasSelectionInRep) {
2023
+ console.log(`${cutLogPrefix} Preventing default CUT on table line with collapsed selection to protect delimiters.`);
2024
+ evt.preventDefault();
2025
+ return;
2026
+ }
1948
2027
 
1949
2028
  // Validate selection is within cell boundaries
1950
2029
  const lineText = rep.lines.atIndex(lineNum)?.text || '';
@@ -1994,37 +2073,37 @@ exports.aceInitialized = (h, ctx) => {
1994
2073
  selEnd[1] = cellEndCol; // clamp
1995
2074
  }
1996
2075
  if (targetCellIndex === -1 || selEnd[1] > cellEndCol) {
1997
- // log(`${cutLogPrefix} WARNING: Selection spans cell boundaries or is outside cells. Preventing cut to protect table structure.`);
2076
+ console.warn(`${cutLogPrefix} WARNING: Selection spans cell boundaries or is outside cells. Preventing cut to protect table structure.`);
1998
2077
  evt.preventDefault();
1999
2078
  return;
2000
2079
  }
2001
2080
 
2002
2081
  // If we reach here, the selection is entirely within a single cell - allow cut and preserve table structure
2003
- // log(`${cutLogPrefix} Selection is entirely within cell ${targetCellIndex}. Intercepting cut to preserve table structure.`);
2082
+ console.log(`${cutLogPrefix} Selection is entirely within cell ${targetCellIndex}. Intercepting cut to preserve table structure.`);
2004
2083
  evt.preventDefault();
2005
2084
 
2006
2085
  try {
2007
2086
  // Get the selected text to copy to clipboard
2008
2087
  const selectedText = lineText.substring(selStart[1], selEnd[1]);
2009
- // log(`${cutLogPrefix} Selected text to cut: "${selectedText}"`);
2088
+ console.log(`${cutLogPrefix} Selected text to cut: "${selectedText}"`);
2010
2089
 
2011
2090
  // Copy to clipboard manually
2012
2091
  if (navigator.clipboard && navigator.clipboard.writeText) {
2013
2092
  navigator.clipboard.writeText(selectedText).then(() => {
2014
- // log(`${cutLogPrefix} Successfully copied to clipboard via Navigator API.`);
2093
+ console.log(`${cutLogPrefix} Successfully copied to clipboard via Navigator API.`);
2015
2094
  }).catch((err) => {
2016
2095
  console.warn(`${cutLogPrefix} Failed to copy to clipboard via Navigator API:`, err);
2017
2096
  });
2018
2097
  } else {
2019
2098
  // Fallback for older browsers
2020
- // log(`${cutLogPrefix} Using fallback clipboard method.`);
2099
+ console.log(`${cutLogPrefix} Using fallback clipboard method.`);
2021
2100
  const textArea = document.createElement('textarea');
2022
2101
  textArea.value = selectedText;
2023
2102
  document.body.appendChild(textArea);
2024
2103
  textArea.select();
2025
2104
  try {
2026
2105
  document.execCommand('copy');
2027
- // log(`${cutLogPrefix} Successfully copied to clipboard via execCommand fallback.`);
2106
+ console.log(`${cutLogPrefix} Successfully copied to clipboard via execCommand fallback.`);
2028
2107
  } catch (err) {
2029
2108
  console.warn(`${cutLogPrefix} Failed to copy to clipboard via fallback:`, err);
2030
2109
  }
@@ -2032,14 +2111,14 @@ exports.aceInitialized = (h, ctx) => {
2032
2111
  }
2033
2112
 
2034
2113
  // Now perform the deletion within the cell using ace operations
2035
- // log(`${cutLogPrefix} Performing deletion via ed.ace_callWithAce.`);
2114
+ console.log(`${cutLogPrefix} Performing deletion via ed.ace_callWithAce.`);
2036
2115
  ed.ace_callWithAce((aceInstance) => {
2037
2116
  const callAceLogPrefix = `${cutLogPrefix}[ace_callWithAceOps]`;
2038
- // log(`${callAceLogPrefix} Entered ace_callWithAce for cut operations. selStart:`, selStart, `selEnd:`, selEnd);
2117
+ console.log(`${callAceLogPrefix} Entered ace_callWithAce for cut operations. selStart:`, selStart, `selEnd:`, selEnd);
2039
2118
 
2040
- // log(`${callAceLogPrefix} Calling aceInstance.ace_performDocumentReplaceRange to delete selected text.`);
2119
+ console.log(`${callAceLogPrefix} Calling aceInstance.ace_performDocumentReplaceRange to delete selected text.`);
2041
2120
  aceInstance.ace_performDocumentReplaceRange(selStart, selEnd, '');
2042
- // log(`${callAceLogPrefix} ace_performDocumentReplaceRange successful.`);
2121
+ console.log(`${callAceLogPrefix} ace_performDocumentReplaceRange successful.`);
2043
2122
 
2044
2123
  // --- Ensure cell is not left empty (zero-length) ---
2045
2124
  const repAfterDeletion = aceInstance.ace_getRep();
@@ -2048,7 +2127,7 @@ exports.aceInitialized = (h, ctx) => {
2048
2127
  const cellTextAfterDeletion = cellsAfterDeletion[targetCellIndex] || '';
2049
2128
 
2050
2129
  if (cellTextAfterDeletion.length === 0) {
2051
- // log(`${callAceLogPrefix} Cell ${targetCellIndex} became empty after cut – inserting single space to preserve structure.`);
2130
+ console.log(`${callAceLogPrefix} Cell ${targetCellIndex} became empty after cut – inserting single space to preserve structure.`);
2052
2131
  const insertPos = [lineNum, selStart[1]]; // Start of the now-empty cell
2053
2132
  aceInstance.ace_performDocumentReplaceRange(insertPos, insertPos, ' ');
2054
2133
 
@@ -2060,9 +2139,9 @@ exports.aceInitialized = (h, ctx) => {
2060
2139
  );
2061
2140
  }
2062
2141
 
2063
- // log(`${callAceLogPrefix} Preparing to re-apply tbljson attribute to line ${lineNum}.`);
2142
+ console.log(`${callAceLogPrefix} Preparing to re-apply tbljson attribute to line ${lineNum}.`);
2064
2143
  const repAfterCut = aceInstance.ace_getRep();
2065
- // log(`${callAceLogPrefix} Fetched rep after cut for applyMeta. Line ${lineNum} text now: "${repAfterCut.lines.atIndex(lineNum).text}"`);
2144
+ console.log(`${callAceLogPrefix} Fetched rep after cut for applyMeta. Line ${lineNum} text now: "${repAfterCut.lines.atIndex(lineNum).text}"`);
2066
2145
 
2067
2146
  ed.ep_data_tables_applyMeta(
2068
2147
  lineNum,
@@ -2074,20 +2153,20 @@ exports.aceInitialized = (h, ctx) => {
2074
2153
  null,
2075
2154
  docManager
2076
2155
  );
2077
- // log(`${callAceLogPrefix} tbljson attribute re-applied successfully via ep_data_tables_applyMeta.`);
2156
+ console.log(`${callAceLogPrefix} tbljson attribute re-applied successfully via ep_data_tables_applyMeta.`);
2078
2157
 
2079
2158
  const newCaretPos = [lineNum, selStart[1]];
2080
- // log(`${callAceLogPrefix} Setting caret position to: [${newCaretPos}].`);
2159
+ console.log(`${callAceLogPrefix} Setting caret position to: [${newCaretPos}].`);
2081
2160
  aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2082
- // log(`${callAceLogPrefix} Selection change successful.`);
2161
+ console.log(`${callAceLogPrefix} Selection change successful.`);
2083
2162
 
2084
- // log(`${callAceLogPrefix} Cut operations within ace_callWithAce completed successfully.`);
2163
+ console.log(`${callAceLogPrefix} Cut operations within ace_callWithAce completed successfully.`);
2085
2164
  }, 'tableCutTextOperations', true);
2086
2165
 
2087
- // log(`${cutLogPrefix} Cut operation completed successfully.`);
2166
+ console.log(`${cutLogPrefix} Cut operation completed successfully.`);
2088
2167
  } catch (error) {
2089
2168
  console.error(`${cutLogPrefix} ERROR during cut operation:`, error);
2090
- // log(`${cutLogPrefix} Cut operation failed. Error details:`, { message: error.message, stack: error.stack });
2169
+ console.log(`${cutLogPrefix} Cut operation failed. Error details:`, { message: error.message, stack: error.stack });
2091
2170
  }
2092
2171
  });
2093
2172
 
@@ -2116,10 +2195,99 @@ exports.aceInitialized = (h, ctx) => {
2116
2195
  const lineNum = selStart[0];
2117
2196
  // log(`${deleteLogPrefix} Current line number: ${lineNum}. Column start: ${selStart[1]}, Column end: ${selEnd[1]}.`);
2118
2197
 
2119
- // Check if there's actually a selection to delete
2120
- if (selStart[0] === selEnd[0] && selStart[1] === selEnd[1]) {
2121
- // log(`${deleteLogPrefix} No selection to delete. Allowing default delete.`);
2122
- return; // Allow default - nothing to delete
2198
+ // Android Chrome IME: collapsed backspace/forward-delete often comes via beforeinput
2199
+ const isAndroidChrome = isAndroidChromiumUA();
2200
+ const inputType = (evt.originalEvent && evt.originalEvent.inputType) || '';
2201
+
2202
+ // Handle collapsed deletes on Android Chrome inside a table line to protect delimiters
2203
+ const isCollapsed = (selStart[0] === selEnd[0] && selStart[1] === selEnd[1]);
2204
+ if (isCollapsed && isAndroidChrome && (inputType === 'deleteContentBackward' || inputType === 'deleteContentForward')) {
2205
+ // Resolve metadata for this line
2206
+ let lineAttrString = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2207
+ let tableMetadata = null;
2208
+ if (lineAttrString) { try { tableMetadata = JSON.parse(lineAttrString); } catch (_) {} }
2209
+ if (!tableMetadata) tableMetadata = getTableLineMetadata(lineNum, ed, docManager);
2210
+ if (!tableMetadata || typeof tableMetadata.cols !== 'number') {
2211
+ return; // Not a table line; allow default
2212
+ }
2213
+
2214
+ // Compute current cell and boundaries
2215
+ const lineText = rep.lines.atIndex(lineNum)?.text || '';
2216
+ const cells = lineText.split(DELIMITER);
2217
+ let currentOffset = 0;
2218
+ let targetCellIndex = -1;
2219
+ let cellStartCol = 0;
2220
+ let cellEndCol = 0;
2221
+ for (let i = 0; i < cells.length; i++) {
2222
+ const cellLength = cells[i]?.length ?? 0;
2223
+ const cellEndColThisIteration = currentOffset + cellLength;
2224
+ if (selStart[1] >= currentOffset && selStart[1] <= cellEndColThisIteration) {
2225
+ targetCellIndex = i;
2226
+ cellStartCol = currentOffset;
2227
+ cellEndCol = cellEndColThisIteration;
2228
+ break;
2229
+ }
2230
+ currentOffset += cellLength + DELIMITER.length;
2231
+ }
2232
+
2233
+ if (targetCellIndex === -1) return; // Allow default if not within a cell
2234
+
2235
+ const isBackward = inputType === 'deleteContentBackward';
2236
+ const caretCol = selStart[1];
2237
+
2238
+ // Prevent deletion across delimiters or line boundaries
2239
+ if ((isBackward && caretCol === cellStartCol) || (!isBackward && caretCol === cellEndCol)) {
2240
+ evt.preventDefault();
2241
+ return;
2242
+ }
2243
+
2244
+ // Intercept and perform one-character deletion via Ace
2245
+ evt.preventDefault();
2246
+ try {
2247
+ ed.ace_callWithAce((aceInstance) => {
2248
+ const delStart = isBackward ? [lineNum, caretCol - 1] : [lineNum, caretCol];
2249
+ const delEnd = isBackward ? [lineNum, caretCol] : [lineNum, caretCol + 1];
2250
+ aceInstance.ace_performDocumentReplaceRange(delStart, delEnd, '');
2251
+
2252
+ // Re-apply table metadata attribute to ensure renderer stability
2253
+ const repAfter = aceInstance.ace_getRep();
2254
+ ed.ep_data_tables_applyMeta(
2255
+ lineNum,
2256
+ tableMetadata.tblId,
2257
+ tableMetadata.row,
2258
+ tableMetadata.cols,
2259
+ repAfter,
2260
+ ed,
2261
+ null,
2262
+ docManager
2263
+ );
2264
+
2265
+ // Set caret position after deletion
2266
+ const newCaretCol = isBackward ? caretCol - 1 : caretCol;
2267
+ const newCaretPos = [lineNum, newCaretCol];
2268
+ aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2269
+ aceInstance.ace_fastIncorp(10);
2270
+
2271
+ // Update last-clicked state if available
2272
+ if (ed.ep_data_tables_editor && ed.ep_data_tables_editor.ep_data_tables_last_clicked && ed.ep_data_tables_editor.ep_data_tables_last_clicked.tblId === tableMetadata.tblId) {
2273
+ const newRelativePos = newCaretCol - cellStartCol;
2274
+ ed.ep_data_tables_editor.ep_data_tables_last_clicked = {
2275
+ lineNum: lineNum,
2276
+ tblId: tableMetadata.tblId,
2277
+ cellIndex: targetCellIndex,
2278
+ relativePos: newRelativePos < 0 ? 0 : newRelativePos,
2279
+ };
2280
+ }
2281
+ }, 'tableCollapsedDeleteHandler', true);
2282
+ } catch (error) {
2283
+ console.error(`${deleteLogPrefix} ERROR handling collapsed delete:`, error);
2284
+ }
2285
+ return;
2286
+ }
2287
+
2288
+ // Non-Android or non-collapsed: require an actual selection to handle here; otherwise allow default
2289
+ if (isCollapsed) {
2290
+ return; // Allow default
2123
2291
  }
2124
2292
 
2125
2293
  // Check if selection spans multiple lines
@@ -2273,6 +2441,319 @@ exports.aceInitialized = (h, ctx) => {
2273
2441
  }
2274
2442
  });
2275
2443
 
2444
+ // Android Chrome IME insert handling (targeted fix)
2445
+ $inner.on('beforeinput', (evt) => {
2446
+ const insertLogPrefix = '[ep_data_tables:beforeinputInsertHandler]';
2447
+ const inputType = evt.originalEvent && evt.originalEvent.inputType || '';
2448
+
2449
+ // Only intercept insert types
2450
+ if (!inputType || !inputType.startsWith('insert')) return;
2451
+
2452
+ // Target only Android Chromium-family browsers (exclude iOS and Firefox)
2453
+ if (!isAndroidChromiumUA()) return;
2454
+
2455
+ // Get current selection and ensure we are inside a table line
2456
+ const rep = ed.ace_getRep();
2457
+ if (!rep || !rep.selStart) return;
2458
+ const selStart = rep.selStart;
2459
+ const selEnd = rep.selEnd;
2460
+ const lineNum = selStart[0];
2461
+
2462
+ // Resolve table metadata for this line
2463
+ let lineAttrString = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2464
+ let tableMetadata = null;
2465
+ if (lineAttrString) {
2466
+ try { tableMetadata = JSON.parse(lineAttrString); } catch (_) {}
2467
+ }
2468
+ if (!tableMetadata) tableMetadata = getTableLineMetadata(lineNum, ed, docManager);
2469
+ if (!tableMetadata || typeof tableMetadata.cols !== 'number' || typeof tableMetadata.tblId === 'undefined' || typeof tableMetadata.row === 'undefined') {
2470
+ return; // not a table line
2471
+ }
2472
+
2473
+ // Compute cell boundaries and target cell index
2474
+ const lineText = rep.lines.atIndex(lineNum)?.text || '';
2475
+ const cells = lineText.split(DELIMITER);
2476
+ let currentOffset = 0;
2477
+ let targetCellIndex = -1;
2478
+ let cellStartCol = 0;
2479
+ let cellEndCol = 0;
2480
+ for (let i = 0; i < cells.length; i++) {
2481
+ const cellLength = cells[i]?.length ?? 0;
2482
+ const cellEndColThisIteration = currentOffset + cellLength;
2483
+ if (selStart[1] >= currentOffset && selStart[1] <= cellEndColThisIteration) {
2484
+ targetCellIndex = i;
2485
+ cellStartCol = currentOffset;
2486
+ cellEndCol = cellEndColThisIteration;
2487
+ break;
2488
+ }
2489
+ currentOffset += cellLength + DELIMITER.length;
2490
+ }
2491
+
2492
+ // Clamp selection end if it includes a trailing delimiter of the same cell
2493
+ if (targetCellIndex !== -1 && selEnd[1] === cellEndCol + DELIMITER.length) {
2494
+ selEnd[1] = cellEndCol;
2495
+ }
2496
+
2497
+ // If selection spills outside the cell boundaries, block to protect structure
2498
+ if (targetCellIndex === -1 || selEnd[1] > cellEndCol) {
2499
+ evt.preventDefault();
2500
+ return;
2501
+ }
2502
+
2503
+ // Handle line breaks by routing to Enter behavior
2504
+ if (inputType === 'insertParagraph' || inputType === 'insertLineBreak') {
2505
+ evt.preventDefault();
2506
+ try {
2507
+ navigateToCellBelow(lineNum, targetCellIndex, tableMetadata, ed, docManager);
2508
+ } catch (e) { console.error(`${insertLogPrefix} Error navigating on line break:`, e); }
2509
+ return;
2510
+ }
2511
+
2512
+ // If we are in Android Chrome composition flow, suppress all insertText until composition ends
2513
+ if (suppressBeforeInputInsertTextDuringComposition && inputType === 'insertText') {
2514
+ evt.preventDefault();
2515
+ return;
2516
+ }
2517
+
2518
+ // If we already handled one insertion via composition handler, skip once (legacy single-shot)
2519
+ if (suppressNextBeforeInputInsertTextOnce && inputType === 'insertText') {
2520
+ suppressNextBeforeInputInsertTextOnce = false;
2521
+ evt.preventDefault();
2522
+ return;
2523
+ }
2524
+
2525
+ // If composition session is active, let composition handler manage
2526
+ if (isAndroidChromeComposition) return;
2527
+
2528
+ // Only proceed for textual insertions we can retrieve
2529
+ const rawData = evt.originalEvent && typeof evt.originalEvent.data === 'string' ? evt.originalEvent.data : '';
2530
+ // If no data for this insert type, allow default (paste/drop paths are handled elsewhere)
2531
+ if (!rawData) return;
2532
+
2533
+ // Sanitize inserted text: remove our delimiter and zero-width characters
2534
+ let insertedText = rawData
2535
+ .replace(new RegExp(DELIMITER, 'g'), ' ')
2536
+ .replace(/[\u200B\u200C\u200D\uFEFF]/g, '')
2537
+ .replace(/\s+/g, ' ');
2538
+
2539
+ if (insertedText.length === 0) {
2540
+ evt.preventDefault();
2541
+ return;
2542
+ }
2543
+
2544
+ // Intercept the browser default and perform the edit via Ace APIs
2545
+ evt.preventDefault();
2546
+ evt.stopPropagation();
2547
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
2548
+
2549
+ try {
2550
+ // Defer to next tick to avoid racing with browser internal composition state
2551
+ setTimeout(() => {
2552
+ ed.ace_callWithAce((aceInstance) => {
2553
+ aceInstance.ace_fastIncorp(10);
2554
+ const freshRep = aceInstance.ace_getRep();
2555
+ const freshSelStart = freshRep.selStart;
2556
+ const freshSelEnd = freshRep.selEnd;
2557
+
2558
+ // Replace selection with sanitized text
2559
+ aceInstance.ace_performDocumentReplaceRange(freshSelStart, freshSelEnd, insertedText);
2560
+
2561
+ // Re-apply cell attribute to the newly inserted text (clamped to line length)
2562
+ const repAfterReplace = aceInstance.ace_getRep();
2563
+ const freshLineIndex = freshSelStart[0];
2564
+ const freshLineEntry = repAfterReplace.lines.atIndex(freshLineIndex);
2565
+ const maxLen = Math.max(0, (freshLineEntry && freshLineEntry.text) ? freshLineEntry.text.length : 0);
2566
+ const startCol = Math.min(Math.max(freshSelStart[1], 0), maxLen);
2567
+ const endColRaw = startCol + insertedText.length;
2568
+ const endCol = Math.min(endColRaw, maxLen);
2569
+ if (endCol > startCol) {
2570
+ aceInstance.ace_performDocumentApplyAttributesToRange(
2571
+ [freshLineIndex, startCol], [freshLineIndex, endCol], [[ATTR_CELL, String(targetCellIndex)]]
2572
+ );
2573
+ }
2574
+
2575
+ // Re-apply table metadata line attribute
2576
+ ed.ep_data_tables_applyMeta(
2577
+ freshLineIndex,
2578
+ tableMetadata.tblId,
2579
+ tableMetadata.row,
2580
+ tableMetadata.cols,
2581
+ repAfterReplace,
2582
+ ed,
2583
+ null,
2584
+ docManager
2585
+ );
2586
+
2587
+ // Move caret to end of inserted text and update last-click state
2588
+ const newCaretCol = endCol;
2589
+ const newCaretPos = [freshLineIndex, newCaretCol];
2590
+ aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2591
+ aceInstance.ace_fastIncorp(10);
2592
+
2593
+ if (editor && editor.ep_data_tables_last_clicked && editor.ep_data_tables_last_clicked.tblId === tableMetadata.tblId) {
2594
+ // Recompute cellStartCol for the fresh line to avoid mismatches
2595
+ const freshLineText = (freshLineEntry && freshLineEntry.text) || '';
2596
+ const freshCells = freshLineText.split(DELIMITER);
2597
+ let freshOffset = 0;
2598
+ for (let i = 0; i < targetCellIndex; i++) {
2599
+ freshOffset += (freshCells[i]?.length ?? 0) + DELIMITER.length;
2600
+ }
2601
+ const newRelativePos = newCaretCol - freshOffset;
2602
+ editor.ep_data_tables_last_clicked = {
2603
+ lineNum: freshLineIndex,
2604
+ tblId: tableMetadata.tblId,
2605
+ cellIndex: targetCellIndex,
2606
+ relativePos: newRelativePos < 0 ? 0 : newRelativePos,
2607
+ };
2608
+ }
2609
+ }, 'tableInsertTextOperations', true);
2610
+ }, 0);
2611
+ } catch (error) {
2612
+ console.error(`${insertLogPrefix} ERROR during insert handling:`, error);
2613
+ }
2614
+ });
2615
+
2616
+ // Composition start marker (Android Chrome/table only)
2617
+ $inner.on('compositionstart', (evt) => {
2618
+ if (!isAndroidChromiumUA()) return;
2619
+ const rep = ed.ace_getRep();
2620
+ if (!rep || !rep.selStart) return;
2621
+ const lineNum = rep.selStart[0];
2622
+ let meta = null; let s = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2623
+ if (s) { try { meta = JSON.parse(s); } catch (_) {} }
2624
+ if (!meta) meta = getTableLineMetadata(lineNum, ed, docManager);
2625
+ if (!meta || typeof meta.cols !== 'number') return;
2626
+ isAndroidChromeComposition = true;
2627
+ handledCurrentComposition = false;
2628
+ suppressBeforeInputInsertTextDuringComposition = false;
2629
+ });
2630
+
2631
+ // Android Chrome composition handling for whitespace (space) to prevent DOM mutation breaking delimiters
2632
+ $inner.on('compositionupdate', (evt) => {
2633
+ const compLogPrefix = '[ep_data_tables:compositionHandler]';
2634
+
2635
+ if (!isAndroidChromiumUA()) return;
2636
+
2637
+ const rep = ed.ace_getRep();
2638
+ if (!rep || !rep.selStart) return;
2639
+ const selStart = rep.selStart;
2640
+ const selEnd = rep.selEnd;
2641
+ const lineNum = selStart[0];
2642
+
2643
+ // Ensure we are inside a table line
2644
+ let lineAttrString = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2645
+ let tableMetadata = null;
2646
+ if (lineAttrString) { try { tableMetadata = JSON.parse(lineAttrString); } catch (_) {} }
2647
+ if (!tableMetadata) tableMetadata = getTableLineMetadata(lineNum, ed, docManager);
2648
+ if (!tableMetadata || typeof tableMetadata.cols !== 'number') return;
2649
+
2650
+ // Only act on whitespace-only composition updates (space/non-breaking space)
2651
+ const d = evt.originalEvent && typeof evt.originalEvent.data === 'string' ? evt.originalEvent.data : '';
2652
+ if (evt.type === 'compositionupdate') {
2653
+ const isWhitespaceOnly = d && d.replace(/\u00A0/g, ' ').trim() === '';
2654
+ if (!isWhitespaceOnly) return;
2655
+
2656
+ // Compute target cell and clamp selection to cell boundaries
2657
+ const lineText = rep.lines.atIndex(lineNum)?.text || '';
2658
+ const cells = lineText.split(DELIMITER);
2659
+ let currentOffset = 0;
2660
+ let targetCellIndex = -1;
2661
+ let cellStartCol = 0;
2662
+ let cellEndCol = 0;
2663
+ for (let i = 0; i < cells.length; i++) {
2664
+ const cellLength = cells[i]?.length ?? 0;
2665
+ const cellEndColThisIteration = currentOffset + cellLength;
2666
+ if (selStart[1] >= currentOffset && selStart[1] <= cellEndColThisIteration) {
2667
+ targetCellIndex = i;
2668
+ cellStartCol = currentOffset;
2669
+ cellEndCol = cellEndColThisIteration;
2670
+ break;
2671
+ }
2672
+ currentOffset += cellLength + DELIMITER.length;
2673
+ }
2674
+ if (targetCellIndex === -1 || selEnd[1] > cellEndCol) return;
2675
+
2676
+ // Prevent composition DOM mutation and insert sanitized space via Ace
2677
+ evt.preventDefault();
2678
+ evt.stopPropagation();
2679
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
2680
+
2681
+ let insertedText = d.replace(/\u00A0/g, ' ');
2682
+ if (insertedText.length === 0) insertedText = ' ';
2683
+
2684
+ try {
2685
+ setTimeout(() => {
2686
+ ed.ace_callWithAce((aceInstance) => {
2687
+ aceInstance.ace_fastIncorp(10);
2688
+ const freshRep = aceInstance.ace_getRep();
2689
+ const freshSelStart = freshRep.selStart;
2690
+ const freshSelEnd = freshRep.selEnd;
2691
+ aceInstance.ace_performDocumentReplaceRange(freshSelStart, freshSelEnd, insertedText);
2692
+
2693
+ // Clamp attribute application to current line bounds and use fresh line index
2694
+ const repAfterReplace = aceInstance.ace_getRep();
2695
+ const freshLineIndex = freshSelStart[0];
2696
+ const freshLineEntry = repAfterReplace.lines.atIndex(freshLineIndex);
2697
+ const maxLen = Math.max(0, (freshLineEntry && freshLineEntry.text) ? freshLineEntry.text.length : 0);
2698
+ const startCol = Math.min(Math.max(freshSelStart[1], 0), maxLen);
2699
+ const endColRaw = startCol + insertedText.length;
2700
+ const endCol = Math.min(endColRaw, maxLen);
2701
+ if (endCol > startCol) {
2702
+ aceInstance.ace_performDocumentApplyAttributesToRange(
2703
+ [freshLineIndex, startCol], [freshLineIndex, endCol], [[ATTR_CELL, String(targetCellIndex)]]
2704
+ );
2705
+ }
2706
+
2707
+ ed.ep_data_tables_applyMeta(
2708
+ freshLineIndex,
2709
+ tableMetadata.tblId,
2710
+ tableMetadata.row,
2711
+ tableMetadata.cols,
2712
+ repAfterReplace,
2713
+ ed,
2714
+ null,
2715
+ docManager
2716
+ );
2717
+
2718
+ const newCaretCol = endCol;
2719
+ const newCaretPos = [freshLineIndex, newCaretCol];
2720
+ aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2721
+ aceInstance.ace_fastIncorp(10);
2722
+ if (editor && editor.ep_data_tables_last_clicked && editor.ep_data_tables_last_clicked.tblId === tableMetadata.tblId) {
2723
+ // Recompute cellStartCol for fresh line
2724
+ const freshLineText = (freshLineEntry && freshLineEntry.text) || '';
2725
+ const freshCells = freshLineText.split(DELIMITER);
2726
+ let freshOffset = 0;
2727
+ for (let i = 0; i < targetCellIndex; i++) {
2728
+ freshOffset += (freshCells[i]?.length ?? 0) + DELIMITER.length;
2729
+ }
2730
+ const newRelativePos = newCaretCol - freshOffset;
2731
+ editor.ep_data_tables_last_clicked = {
2732
+ lineNum: freshLineIndex,
2733
+ tblId: tableMetadata.tblId,
2734
+ cellIndex: targetCellIndex,
2735
+ relativePos: newRelativePos < 0 ? 0 : newRelativePos,
2736
+ };
2737
+ }
2738
+ }, 'tableCompositionSpaceInsert', true);
2739
+ }, 0);
2740
+ // Suppress all subsequent beforeinput insertText events in this composition session
2741
+ suppressBeforeInputInsertTextDuringComposition = true;
2742
+ } catch (error) {
2743
+ console.error(`${compLogPrefix} ERROR inserting space during composition:`, error);
2744
+ }
2745
+ }
2746
+ });
2747
+
2748
+ // Composition end cleanup
2749
+ $inner.on('compositionend', () => {
2750
+ if (isAndroidChromeComposition) {
2751
+ isAndroidChromeComposition = false;
2752
+ handledCurrentComposition = false;
2753
+ suppressBeforeInputInsertTextDuringComposition = false;
2754
+ }
2755
+ });
2756
+
2276
2757
  // *** DRAG AND DROP EVENT LISTENERS ***
2277
2758
  // log(`${callWithAceLogPrefix} Attaching drag and drop event listeners to $inner (inner iframe body).`);
2278
2759
 
@@ -2281,6 +2762,18 @@ exports.aceInitialized = (h, ctx) => {
2281
2762
  const dropLogPrefix = '[ep_data_tables:dropHandler]';
2282
2763
  // log(`${dropLogPrefix} DROP EVENT TRIGGERED. Event object:`, evt);
2283
2764
 
2765
+ // Block drops directly targeted at a table element regardless of selection state
2766
+ const targetEl = evt.target;
2767
+ if (targetEl && typeof targetEl.closest === 'function' && targetEl.closest('table.dataTable')) {
2768
+ evt.preventDefault();
2769
+ evt.stopPropagation();
2770
+ if (evt.originalEvent && evt.originalEvent.dataTransfer) {
2771
+ try { evt.originalEvent.dataTransfer.dropEffect = 'none'; } catch (_) {}
2772
+ }
2773
+ console.warn('[ep_data_tables] Drop prevented on table to protect structure.');
2774
+ return;
2775
+ }
2776
+
2284
2777
  // log(`${dropLogPrefix} Getting current editor representation (rep).`);
2285
2778
  const rep = ed.ace_getRep();
2286
2779
  if (!rep || !rep.selStart) {
@@ -2314,6 +2807,17 @@ exports.aceInitialized = (h, ctx) => {
2314
2807
  $inner.on('dragover', (evt) => {
2315
2808
  const dragLogPrefix = '[ep_data_tables:dragoverHandler]';
2316
2809
 
2810
+ // If hovering over a table, signal not-allowed and block default
2811
+ const targetEl = evt.target;
2812
+ if (targetEl && typeof targetEl.closest === 'function' && targetEl.closest('table.dataTable')) {
2813
+ if (evt.originalEvent && evt.originalEvent.dataTransfer) {
2814
+ try { evt.originalEvent.dataTransfer.dropEffect = 'none'; } catch (_) {}
2815
+ }
2816
+ evt.preventDefault();
2817
+ evt.stopPropagation();
2818
+ return;
2819
+ }
2820
+
2317
2821
  const rep = ed.ace_getRep();
2318
2822
  if (!rep || !rep.selStart) {
2319
2823
  return; // Allow default
@@ -2337,6 +2841,18 @@ exports.aceInitialized = (h, ctx) => {
2337
2841
  }
2338
2842
  });
2339
2843
 
2844
+ // Guard against dragenter into table areas
2845
+ $inner.on('dragenter', (evt) => {
2846
+ const targetEl = evt.target;
2847
+ if (targetEl && typeof targetEl.closest === 'function' && targetEl.closest('table.dataTable')) {
2848
+ if (evt.originalEvent && evt.originalEvent.dataTransfer) {
2849
+ try { evt.originalEvent.dataTransfer.dropEffect = 'none'; } catch (_) {}
2850
+ }
2851
+ evt.preventDefault();
2852
+ evt.stopPropagation();
2853
+ }
2854
+ });
2855
+
2340
2856
  // *** EXISTING PASTE LISTENER ***
2341
2857
  // log(`${callWithAceLogPrefix} Attaching paste event listener to $inner (inner iframe body).`);
2342
2858
  $inner.on('paste', (evt) => {
@@ -2791,14 +3307,15 @@ exports.aceInitialized = (h, ctx) => {
2791
3307
  // *** DRAG PREVENTION FOR TABLE ELEMENTS ***
2792
3308
  const preventTableDrag = (evt) => {
2793
3309
  const target = evt.target;
2794
- // Check if the target is a table element or inside a table
2795
- if (target.tagName === 'TABLE' && target.classList.contains('dataTable') ||
2796
- target.tagName === 'TD' && target.closest('table.dataTable') ||
2797
- target.tagName === 'TR' && target.closest('table.dataTable') ||
2798
- target.tagName === 'TBODY' && target.closest('table.dataTable')) {
2799
- // log('[ep_data_tables:dragPrevention] Preventing drag operation on table element:', target.tagName);
3310
+ // Block ANY drag gesture that originates within a table (including spans/text inside cells)
3311
+ const inTable = target && typeof target.closest === 'function' && target.closest('table.dataTable');
3312
+ if (inTable) {
3313
+ // log('[ep_data_tables:dragPrevention] Preventing drag operation originating from inside table');
2800
3314
  evt.preventDefault();
2801
3315
  evt.stopPropagation();
3316
+ if (evt.originalEvent && evt.originalEvent.dataTransfer) {
3317
+ try { evt.originalEvent.dataTransfer.effectAllowed = 'none'; } catch (_) {}
3318
+ }
2802
3319
  return false;
2803
3320
  }
2804
3321
  };
@@ -2808,6 +3325,21 @@ exports.aceInitialized = (h, ctx) => {
2808
3325
  $inner.on('drag', preventTableDrag);
2809
3326
  $inner.on('dragend', preventTableDrag);
2810
3327
  // log(`${callWithAceLogPrefix} Attached drag prevention handlers to inner body`);
3328
+
3329
+ // Attach drag prevention broadly to cover iframe boundaries and the host document
3330
+ if (innerDoc.length > 0) {
3331
+ innerDoc.on('dragstart', preventTableDrag);
3332
+ innerDoc.on('drag', preventTableDrag);
3333
+ innerDoc.on('dragend', preventTableDrag);
3334
+ }
3335
+ if (outerDoc.length > 0) {
3336
+ outerDoc.on('dragstart', preventTableDrag);
3337
+ outerDoc.on('drag', preventTableDrag);
3338
+ outerDoc.on('dragend', preventTableDrag);
3339
+ }
3340
+ $(document).on('dragstart', preventTableDrag);
3341
+ $(document).on('drag', preventTableDrag);
3342
+ $(document).on('dragend', preventTableDrag);
2811
3343
  };
2812
3344
 
2813
3345
  // Setup the global handlers
@@ -4239,203 +4771,4 @@ exports.aceUndoRedo = (hook, ctx) => {
4239
4771
  console.error(`${logPrefix} Error during undo/redo validation:`, e);
4240
4772
  // log(`${logPrefix} Error details:`, { message: e.message, stack: e.stack });
4241
4773
  }
4242
- };
4243
-
4244
- // *** ADDED: postAceInit hook for attaching listeners ***
4245
- exports.postAceInit = (hookName, ctx) => {
4246
- const func = '[ep_data_tables:postAceInit]';
4247
- // log(`${func} START`);
4248
- const editorInfo = ctx.ace; // Get editorInfo from context
4249
-
4250
- if (!editorInfo) {
4251
- console.error(`${func} ERROR: editorInfo (ctx.ace) is not available.`);
4252
- return;
4253
- }
4254
-
4255
- const attachReconnectHandler = () => {
4256
- try {
4257
- const padObj = window.pad;
4258
- const socket = padObj && padObj.socket;
4259
- if (!socket) return false; // Not ready yet
4260
-
4261
- if (socket.ep_data_tables_reconnect_listener_attached) return true;
4262
-
4263
- let triggered = false;
4264
- const triggerHardReconnect = (evtName) => {
4265
- if (triggered) return;
4266
- triggered = true;
4267
- console.log(`[ep_data_tables] Socket.IO event '${evtName}' – invoking pad.forceReconnect()`);
4268
- if (window.pad && typeof window.pad.forceReconnect === 'function') {
4269
- try { window.pad.forceReconnect(); } catch(e) { console.error('[ep_data_tables] pad.forceReconnect() failed', e); window.location.reload(); }
4270
- } else {
4271
- window.location.reload();
4272
- }
4273
- };
4274
-
4275
- socket.on('reconnect_attempt', () => triggerHardReconnect('reconnect_attempt'));
4276
- socket.on('reconnect', () => triggerHardReconnect('reconnect'));
4277
- socket.on('connect', () => { if (socket.disconnectedPreviously) triggerHardReconnect('connect'); });
4278
- socket.on('disconnect', () => { socket.disconnectedPreviously = true; });
4279
-
4280
- socket.ep_data_tables_reconnect_listener_attached = true;
4281
- console.log('[ep_data_tables] Reconnect handler fully attached to pad.socket');
4282
- return true;
4283
- } catch (e) {
4284
- console.error('[ep_data_tables] Error attaching reconnect listener:', e);
4285
- return false;
4286
- }
4287
- };
4288
-
4289
- // Keep trying until it attaches (no max attempts)
4290
- if (!attachReconnectHandler()) {
4291
- const intervalId = setInterval(() => {
4292
- if (attachReconnectHandler()) clearInterval(intervalId);
4293
- }, 500);
4294
- }
4295
-
4296
- // Setup mousedown listener via callWithAce
4297
- editorInfo.ace_callWithAce((ace) => {
4298
- const editor = ace.editor;
4299
- const inner = ace.editor.container; // Use the main container
4300
-
4301
- if (!editor || !inner) {
4302
- console.error(`${func} ERROR: ace.editor or ace.editor.container not found within ace_callWithAce.`);
4303
- return;
4304
- }
4305
-
4306
- // log(`${func} Inside callWithAce for attaching mousedown listeners.`);
4307
-
4308
- // Initialize shared state on the editor object
4309
- if (!editor.ep_data_tables_last_clicked) {
4310
- editor.ep_data_tables_last_clicked = null;
4311
- // log(`${func} Initialized ace.editor.ep_data_tables_last_clicked`);
4312
- }
4313
-
4314
- // log(`${func} Attempting to attach mousedown listener to editor container for cell selection...`);
4315
-
4316
- inner.addEventListener('mousedown', (evt) => {
4317
- const target = evt.target;
4318
- const mousedownFuncName = '[ep_data_tables mousedown]';
4319
- // log(`${mousedownFuncName} RAW MOUSE DOWN detected. Target:`, target);
4320
- // log(`${mousedownFuncName} Target tagName: ${target.tagName}`);
4321
- // log(`${mousedownFuncName} Target className: ${target.className}`);
4322
- // log(`${mousedownFuncName} Target ID: ${target.id}`);
4323
-
4324
- // Don't interfere with resize handle clicks
4325
- if (target.classList && target.classList.contains('ep-data_tables-resize-handle')) {
4326
- // log(`${mousedownFuncName} Click on resize handle, skipping cell selection logic.`);
4327
- return;
4328
- }
4329
-
4330
- // *** ENHANCED DEBUG: Check for image-related elements ***
4331
- const $target = $(target);
4332
- const isImageElement = $target.closest('.inline-image, .image-placeholder, .image-inner, .image-resize-handle').length > 0;
4333
- // log(`${mousedownFuncName} Is target or ancestor image-related?`, isImageElement);
4334
-
4335
- if (isImageElement) {
4336
- // log(`${mousedownFuncName} *** IMAGE ELEMENT DETECTED ***`);
4337
- // log(`${mousedownFuncName} Closest image container:`, $target.closest('.inline-image, .image-placeholder')[0]);
4338
- // log(`${mousedownFuncName} Is .inline-image:`, $target.hasClass('inline-image') || $target.closest('.inline-image').length > 0);
4339
- // log(`${mousedownFuncName} Is .image-placeholder:`, $target.hasClass('image-placeholder') || $target.closest('.image-placeholder').length > 0);
4340
- // log(`${mousedownFuncName} Is .image-inner:`, $target.hasClass('image-inner') || $target.closest('.image-inner').length > 0);
4341
- // log(`${mousedownFuncName} Is .image-resize-handle:`, $target.hasClass('image-resize-handle') || $target.closest('.image-resize-handle').length > 0);
4342
- }
4343
-
4344
- // Check if the click is on an image or image-related element - if so, completely skip
4345
- if (isImageElement) {
4346
- // log(`${mousedownFuncName} Click detected on image element within table cell. Completely skipping table processing to avoid interference.`);
4347
- return;
4348
- }
4349
-
4350
- // *** ENHANCED DEBUG: Check table context ***
4351
- const clickedTD = target.closest('td');
4352
- const clickedTR = target.closest('tr');
4353
- const clickedTable = target.closest('table.dataTable');
4354
-
4355
- // log(`${mousedownFuncName} Table context analysis:`);
4356
- // log(`${mousedownFuncName} - Clicked TD:`, !!clickedTD);
4357
- // log(`${mousedownFuncName} - Clicked TR:`, !!clickedTR);
4358
- // log(`${mousedownFuncName} - Clicked table.dataTable:`, !!clickedTable);
4359
-
4360
- if (clickedTable) {
4361
- // log(`${mousedownFuncName} - Table tblId:`, clickedTable.getAttribute('data-tblId'));
4362
- // log(`${mousedownFuncName} - Table row:`, clickedTable.getAttribute('data-row'));
4363
- }
4364
- if (clickedTD) {
4365
- // log(`${mousedownFuncName} - TD data-column:`, clickedTD.getAttribute('data-column'));
4366
- // log(`${mousedownFuncName} - TD innerHTML length:`, clickedTD.innerHTML?.length || 0);
4367
- // log(`${mousedownFuncName} - TD contains images:`, clickedTD.querySelector('.inline-image, .image-placeholder') ? 'YES' : 'NO');
4368
- }
4369
-
4370
- // Clear previous selection state regardless of where click happened
4371
- if (editor.ep_data_tables_last_clicked) {
4372
- // log(`${mousedownFuncName} Clearing previous selection info.`);
4373
- // TODO: Add visual class removal if needed
4374
- }
4375
- editor.ep_data_tables_last_clicked = null; // Clear state first
4376
-
4377
- if (clickedTD && clickedTR && clickedTable) {
4378
- // log(`${mousedownFuncName} Click detected inside table.dataTable td.`);
4379
- try {
4380
- const cellIndex = Array.from(clickedTR.children).indexOf(clickedTD);
4381
- const lineNode = clickedTable.closest('div.ace-line');
4382
- const tblId = clickedTable.getAttribute('data-tblId');
4383
-
4384
- // log(`${mousedownFuncName} Cell analysis:`);
4385
- // log(`${mousedownFuncName} - Cell index:`, cellIndex);
4386
- // log(`${mousedownFuncName} - Line node:`, !!lineNode);
4387
- // log(`${mousedownFuncName} - Line node ID:`, lineNode?.id);
4388
- // log(`${mousedownFuncName} - Table ID:`, tblId);
4389
-
4390
- // Ensure ace.rep and ace.rep.lines are available
4391
- if (!ace.rep || !ace.rep.lines) {
4392
- console.error(`${mousedownFuncName} ERROR: ace.rep or ace.rep.lines not available inside mousedown listener.`);
4393
- return;
4394
- }
4395
-
4396
- if (lineNode && lineNode.id && tblId !== null && cellIndex !== -1) {
4397
- const lineNum = ace.rep.lines.indexOfKey(lineNode.id);
4398
- if (lineNum !== -1) {
4399
- // Store the accurately determined cell info
4400
- // Initialize relative position - might be refined later if needed
4401
- const clickInfo = { lineNum, tblId, cellIndex, relativePos: 0 }; // Set initial relativePos to 0
4402
- editor.ep_data_tables_last_clicked = clickInfo;
4403
- // log(`${mousedownFuncName} Clicked cell (SUCCESS): Line=${lineNum}, TblId=${tblId}, CellIndex=${cellIndex}. Stored click info:`, clickInfo);
4404
-
4405
- // --- NEW: Jump caret immediately for snappier UX ---
4406
- try {
4407
- const docMgr = ace.ep_data_tables_docManager;
4408
- if (docMgr && typeof navigateToCell === 'function') {
4409
- const navOk = navigateToCell(lineNum, cellIndex, ace, docMgr);
4410
- // log(`${mousedownFuncName} Immediate navigateToCell result: ${navOk}`);
4411
- }
4412
- } catch (navErr) {
4413
- console.error(`${mousedownFuncName} Error during immediate caret navigation:`, navErr);
4414
- }
4415
-
4416
- // TODO: Add visual class for selection if desired
4417
- // log(`${mousedownFuncName} TEST: Skipped adding/removing selected-table-cell class`);
4418
-
4419
- } else {
4420
- // log(`${mousedownFuncName} Clicked cell (ERROR): Could not find line number for node ID: ${lineNode.id}`);
4421
- }
4422
- } else {
4423
- // log(`${mousedownFuncName} Clicked cell (ERROR): Missing required info (lineNode, lineNode.id, tblId, or valid cellIndex).`, { lineNode, tblId, cellIndex });
4424
- }
4425
- } catch (e) {
4426
- console.error(`${mousedownFuncName} Error processing table cell click:`, e);
4427
- // log(`${mousedownFuncName} Error details:`, { message: e.message, stack: e.stack });
4428
- editor.ep_data_tables_last_clicked = null; // Ensure state is clear on error
4429
- }
4430
- } else {
4431
- // log(`${mousedownFuncName} Click was outside a table.dataTable td.`);
4432
- }
4433
- });
4434
- // log(`${func} Mousedown listeners for cell selection attached successfully (inside callWithAce).`);
4435
-
4436
- }, 'tableCellSelectionPostAce', true); // Unique name for callstack
4437
-
4438
- // log(`${func} END`);
4439
- };
4440
-
4441
- // END OF FILE
4774
+ };
@@ -1,13 +0,0 @@
1
- const log = (...m) => console.debug('[ep_data_tables:collector_hooks]', ...m);
2
-
3
- exports.collectContentPre = (hook, ctx) => {
4
- return;
5
- };
6
-
7
- exports.collectContentLineBreak = (hook, ctx) => {
8
- return true;
9
- };
10
-
11
- exports.collectContentLineText = (_hook, ctx) => {
12
- return ctx.text || '';
13
- };