ep_data_tables 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_data_tables",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "BETA - etherpad tables plugin, compatible with other character/line based styling and other features",
5
5
  "author": {
6
6
  "name": "DCastelone",
@@ -22,6 +22,8 @@ const DELIMITER = '\u241F'; // Internal column delimiter (␟)
22
22
  // still find delimiters when it splits node.innerHTML.
23
23
  // Users never see this because the span is contenteditable=false and styled away.
24
24
  const HIDDEN_DELIM = DELIMITER;
25
+ // InputEvent inputTypes used by mobile autocorrect/IME commit
26
+ const INPUTTYPE_REPLACEMENT_TYPES = new Set(['insertReplacementText', 'insertFromComposition']);
25
27
 
26
28
  // helper for stable random ids
27
29
  const rand = () => Math.random().toString(36).slice(2, 8);
@@ -75,6 +77,12 @@ function isAndroidUA() {
75
77
  return isAndroid && !isIOS;
76
78
  }
77
79
 
80
+ // Helper to detect any iOS browser (Safari, Chrome iOS, Firefox iOS, Edge iOS)
81
+ function isIOSUA() {
82
+ const ua = (navigator.userAgent || '').toLowerCase();
83
+ return ua.includes('iphone') || ua.includes('ipad') || ua.includes('ipod') || ua.includes('ios') || ua.includes('crios') || ua.includes('fxios') || ua.includes('edgios');
84
+ }
85
+
78
86
  // ─────────────────── Reusable Helper Functions ───────────────────
79
87
 
80
88
  /**
@@ -671,7 +679,6 @@ function escapeHtml(text = '') {
671
679
  };
672
680
  return strText.replace(/[&<>"'']/g, function(m) { return map[m]; });
673
681
  }
674
-
675
682
  // NEW Helper function to build table HTML from pre-rendered delimited content with resize handles
676
683
  function buildTableFromDelimitedHTML(metadata, innerHTMLSegments) {
677
684
  const funcName = 'buildTableFromDelimitedHTML';
@@ -734,6 +741,42 @@ function buildTableFromDelimitedHTML(metadata, innerHTMLSegments) {
734
741
  ? `<span class="${encodedTbljsonClass ? `${encodedTbljsonClass} ` : ''}tblCell-${index}">${modifiedSegment}${caretAnchorSpan}</span>`
735
742
  : `${modifiedSegment}${caretAnchorSpan}`);
736
743
 
744
+ // Normalize: ensure first content span has correct tblCell-N and strip tblCell-* from subsequent spans
745
+ try {
746
+ const requiredCellClass = `tblCell-${index}`;
747
+ // Skip a leading delimiter span if present
748
+ const leadingDelimMatch = modifiedSegment.match(/^\s*<span[^>]*\bep-data_tables-delim\b[^>]*>[\s\S]*?<\/span>\s*/i);
749
+ const head = leadingDelimMatch ? leadingDelimMatch[0] : '';
750
+ const tail = leadingDelimMatch ? modifiedSegment.slice(head.length) : modifiedSegment;
751
+
752
+ const openSpanMatch = tail.match(/^\s*<span([^>]*)>/i);
753
+ if (!openSpanMatch) {
754
+ // No span at start: wrap with required cell class. Keep tbljson if available for stability.
755
+ const baseClasses = `${encodedTbljsonClass ? `${encodedTbljsonClass} ` : ''}${requiredCellClass}`;
756
+ modifiedSegment = `${head}<span class="${baseClasses}">${tail}</span>`;
757
+ } else {
758
+ const fullOpen = openSpanMatch[0];
759
+ const attrs = openSpanMatch[1] || '';
760
+ const classMatch = /\bclass\s*=\s*"([^"]*)"/i.exec(attrs);
761
+ let classList = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : [];
762
+ // Remove any stale tblCell-* from the first span's class list
763
+ classList = classList.filter(c => !/^tblCell-\d+$/.test(c));
764
+ // Ensure required cell class exists
765
+ classList.push(requiredCellClass);
766
+ const unique = Array.from(new Set(classList));
767
+ const newClassAttr = ` class="${unique.join(' ')}"`;
768
+ const attrsWithoutClass = classMatch ? attrs.replace(/\s*class\s*=\s*"[^"]*"/i, '') : attrs;
769
+ const rebuiltOpen = `<span${newClassAttr}${attrsWithoutClass}>`;
770
+ const afterOpen = tail.slice(fullOpen.length);
771
+ // Remove tblCell-* from any subsequent spans to avoid cascading mismatches (keep tbljson- as-is)
772
+ const cleanedTail = afterOpen.replace(/(<span[^>]*class=")([^"]*)(")/ig, (m, p1, classes, p3) => {
773
+ const filtered = classes.split(/\s+/).filter(c => c && !/^tblCell-\d+$/.test(c)).join(' ');
774
+ return p1 + filtered + p3;
775
+ });
776
+ modifiedSegment = head + rebuiltOpen + cleanedTail;
777
+ }
778
+ } catch (_) { /* ignore normalization errors */ }
779
+
737
780
  // Width & other decorations remain unchanged
738
781
  const widthPercent = columnWidths[index] || (100 / numCols);
739
782
  const cellStyle = `${tdStyle} width: ${widthPercent}%;`;
@@ -964,11 +1007,15 @@ exports.acePostWriteDomLineHTML = function (hook_name, args, cb) {
964
1007
  // NEW: Remove all hidden-delimiter <span> wrappers **before** we split so
965
1008
  // the embedded delimiter character they carry doesn't inflate or shrink
966
1009
  // the segment count.
967
- const spanDelimRegex = new RegExp('<span class="ep-data_tables-delim"[^>]*>' + DELIMITER + '<\\/span>', 'ig');
1010
+ // Preserve the delimiter character when stripping its wrapper span so splitting works
1011
+ const spanDelimRegex = /<span class="ep-data_tables-delim"[^>]*>[\s\S]*?<\/span>/ig;
968
1012
  const sanitizedHTMLForSplit = (delimitedTextFromLine || '')
969
- .replace(spanDelimRegex, '')
1013
+ .replace(spanDelimRegex, DELIMITER)
970
1014
  // strip caret anchors from raw line html before split
971
- .replace(/<span class="ep-data_tables-caret-anchor"[^>]*><\/span>/ig, '');
1015
+ .replace(/<span class="ep-data_tables-caret-anchor"[^>]*><\/span>/ig, '')
1016
+ .replace(/\r?\n/g, ' ')
1017
+ .replace(/\u00A0/gu, ' ')
1018
+ .replace(/<br\s*\/?>/gi, ' ');
972
1019
  const htmlSegments = sanitizedHTMLForSplit.split(DELIMITER);
973
1020
 
974
1021
  // log(`${logPrefix} NodeID#${nodeId}: *** SEGMENT ANALYSIS ***`);
@@ -987,6 +1034,16 @@ exports.acePostWriteDomLineHTML = function (hook_name, args, cb) {
987
1034
  if (segment.includes('image:') || segment.includes('image-placeholder') || segment.includes('currently-selected')) {
988
1035
  // log(`${logPrefix} NodeID#${nodeId}: *** SEGMENT[${i}] CONTAINS IMAGE CONTENT ***`);
989
1036
  }
1037
+ // Diagnostics: surface suspicious class patterns that can precede breakage
1038
+ try {
1039
+ const tblCellMatches = segment.match(/\btblCell-(\d+)\b/g) || [];
1040
+ const tbljsonMatches = segment.match(/\btbljson-[A-Za-z0-9_-]+\b/g) || [];
1041
+ const uniqueCells = Array.from(new Set(tblCellMatches));
1042
+ if (uniqueCells.length > 1) {
1043
+ console.warn('[ep_data_tables][diag] segment contains multiple tblCell-* markers', { segIndex: i, uniqueCells });
1044
+ }
1045
+ // intentionally ignore duplicate tbljson-* occurrences – not root cause
1046
+ } catch (_) {}
990
1047
  }
991
1048
 
992
1049
  // log(`${logPrefix} NodeID#${nodeId}: Parsed HTML segments (${htmlSegments.length}):`, htmlSegments.map(s => (s || '').substring(0,50) + (s && s.length > 50 ? '...' : '')));
@@ -996,7 +1053,8 @@ exports.acePostWriteDomLineHTML = function (hook_name, args, cb) {
996
1053
 
997
1054
  if (htmlSegments.length !== rowMetadata.cols) {
998
1055
  // log(`${logPrefix} NodeID#${nodeId}: *** MISMATCH DETECTED *** - Attempting reconstruction.`);
999
-
1056
+ console.warn('[ep_data_tables][diag] Segment/column mismatch', { nodeId, lineNum, segs: htmlSegments.length, cols: rowMetadata.cols, tblId: rowMetadata.tblId, row: rowMetadata.row });
1057
+
1000
1058
  // Check if this is an image selection issue
1001
1059
  const hasImageSelected = delimitedTextFromLine.includes('currently-selected');
1002
1060
  const hasImageContent = delimitedTextFromLine.includes('image:');
@@ -1004,47 +1062,9 @@ exports.acePostWriteDomLineHTML = function (hook_name, args, cb) {
1004
1062
  // log(`${logPrefix} NodeID#${nodeId}: *** POTENTIAL CAUSE: Image selection state may be affecting segment parsing ***`);
1005
1063
  }
1006
1064
 
1007
- // First attempt: reconstruct using DOM spans that carry tblCell-N classes
1065
+ // First attempt (DISABLED): class-based reconstruction caused content loss in real pads.
1066
+ // We now rely on sanitized delimiter splitting and safe padding/truncation instead.
1008
1067
  let usedClassReconstruction = false;
1009
- try {
1010
- const cols = Math.max(0, Number(rowMetadata.cols) || 0);
1011
- const grouped = Array.from({ length: cols }, () => '');
1012
- const candidates = Array.from(node.querySelectorAll('[class*="tblCell-"]'));
1013
-
1014
- const classNum = (el) => {
1015
- if (!el || !el.classList) return -1;
1016
- for (const cls of el.classList) {
1017
- const m = /^tblCell-(\d+)$/.exec(cls);
1018
- if (m) return parseInt(m[1], 10);
1019
- }
1020
- return -1;
1021
- };
1022
- const hasAncestorWithSameCell = (el, n) => {
1023
- let p = el?.parentElement;
1024
- while (p) {
1025
- if (p.classList && p.classList.contains(`tblCell-${n}`)) return true;
1026
- p = p.parentElement;
1027
- }
1028
- return false;
1029
- };
1030
-
1031
- for (const el of candidates) {
1032
- const n = classNum(el);
1033
- if (n >= 0 && n < cols) {
1034
- if (!hasAncestorWithSameCell(el, n)) {
1035
- grouped[n] += el.outerHTML || '';
1036
- }
1037
- }
1038
- }
1039
- const usable = grouped.some(s => s && s.trim() !== '');
1040
- if (usable) {
1041
- finalHtmlSegments = grouped.map(s => (s && s.trim() !== '') ? s : '&nbsp;');
1042
- usedClassReconstruction = true;
1043
- console.warn(`[ep_data_tables] ${funcName} NodeID#${nodeId}: Reconstructed ${finalHtmlSegments.length} segments from tblCell-N classes.`);
1044
- }
1045
- } catch (e) {
1046
- console.debug(`[ep_data_tables] ${funcName} NodeID#${nodeId}: Class-based reconstruction error; falling back.`, e);
1047
- }
1048
1068
 
1049
1069
  // Fallback: reconstruct from string segments
1050
1070
  if (!usedClassReconstruction) {
@@ -1133,7 +1153,6 @@ function _getLineNumberOfElement(element) {
1133
1153
  }
1134
1154
  return count;
1135
1155
  }
1136
-
1137
1156
  // ───────────────────── Handle Key Events ─────────────────────
1138
1157
  exports.aceKeyEvent = (h, ctx) => {
1139
1158
  const funcName = 'aceKeyEvent';
@@ -1887,7 +1906,6 @@ exports.aceKeyEvent = (h, ctx) => {
1887
1906
  // log(`${logPrefix} [caretTrace] Final rep.selStart at end of aceKeyEvent (if unhandled): Line=${rep.selStart[0]}, Col=${rep.selStart[1]}`);
1888
1907
  return false; // Allow default browser/ACE handling
1889
1908
  };
1890
-
1891
1909
  // ───────────────────── ace init + public helpers ─────────────────────
1892
1910
  exports.aceInitialized = (h, ctx) => {
1893
1911
  const logPrefix = '[ep_data_tables:aceInitialized]';
@@ -1951,6 +1969,121 @@ exports.aceInitialized = (h, ctx) => {
1951
1969
  }
1952
1970
  $inner = $(innerDocBody[0]); // Ensure it's a jQuery object of the body itself
1953
1971
  // log(`${callWithAceLogPrefix} Successfully found inner iframe body:`, $inner);
1972
+
1973
+ // ──────────────────────────────────────────────────────────────────────────────
1974
+ // Mobile suggestion / autocorrect guard (Android and iOS)
1975
+ // Many virtual keyboards commit the chosen suggestion AFTER the composition
1976
+ // session has ended. This arrives as a second `beforeinput` (or `input` on
1977
+ // Safari) with inputType = "insertReplacementText" or "insertFromComposition"
1978
+ // – sometimes just plain "insertText" with isComposing=false. The default DOM
1979
+ // mutation breaks our \u241F-delimited table lines, causing renderer failure.
1980
+ //
1981
+ // We intercept these in the capture phase, cancel the event, and inject a
1982
+ // single safe space via Ace APIs so the caret advances but the table
1983
+ // structure remains intact.
1984
+ // ──────────────────────────────────────────────────────────────────────────────
1985
+ const mobileSuggestionBlocker = (evt) => {
1986
+ const t = evt && evt.inputType || '';
1987
+ const dataStr = (evt && typeof evt.data === 'string') ? evt.data : '';
1988
+ const isProblem = (
1989
+ t === 'insertReplacementText' ||
1990
+ t === 'insertFromComposition' ||
1991
+ (t === 'insertText' && !evt.isComposing && (!dataStr || dataStr.length > 1))
1992
+ );
1993
+ if (!isProblem) return;
1994
+
1995
+ // Only act if selection is inside a table line
1996
+ try {
1997
+ const repQuick = ed.ace_getRep && ed.ace_getRep();
1998
+ if (!repQuick || !repQuick.selStart) return; // don't block outside editor selection
1999
+ const lineNumQuick = repQuick.selStart[0];
2000
+ let metaStrQuick = docManager && docManager.getAttributeOnLine
2001
+ ? docManager.getAttributeOnLine(lineNumQuick, ATTR_TABLE_JSON)
2002
+ : null;
2003
+ let metaQuick = null;
2004
+ if (metaStrQuick) { try { metaQuick = JSON.parse(metaStrQuick); } catch (_) {} }
2005
+ if (!metaQuick) metaQuick = getTableLineMetadata(lineNumQuick, ed, docManager);
2006
+ if (!metaQuick || typeof metaQuick.cols !== 'number') return; // not a table line → do not block
2007
+ } catch (_) { return; }
2008
+
2009
+ // Cancel the browser's DOM mutation early
2010
+ evt.preventDefault();
2011
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
2012
+
2013
+ // Replace selection with a single plain space using Ace APIs
2014
+ setTimeout(() => {
2015
+ try {
2016
+ ed.ace_callWithAce((aceInstance) => {
2017
+ aceInstance.ace_fastIncorp(10);
2018
+ const rep = aceInstance.ace_getRep();
2019
+ if (!rep || !rep.selStart) return;
2020
+ const lineNum = rep.selStart[0];
2021
+
2022
+ // Perform the safe insertion
2023
+ aceInstance.ace_performDocumentReplaceRange(rep.selStart, rep.selEnd, ' ');
2024
+
2025
+ // Try to re-apply table metadata attribute to stabilize renderer
2026
+ try {
2027
+ let metaStr = docManager && docManager.getAttributeOnLine
2028
+ ? docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON)
2029
+ : null;
2030
+ let meta = null;
2031
+ if (metaStr) { try { meta = JSON.parse(metaStr); } catch (_) {} }
2032
+ if (!meta) meta = getTableLineMetadata(lineNum, ed, docManager);
2033
+ if (meta && typeof meta.tblId !== 'undefined' && typeof meta.row !== 'undefined' && typeof meta.cols === 'number') {
2034
+ const repAfter = aceInstance.ace_getRep();
2035
+ ed.ep_data_tables_applyMeta(lineNum, meta.tblId, meta.row, meta.cols, repAfter, ed, null, docManager);
2036
+ }
2037
+ } catch (_) {}
2038
+ }, 'mobileSuggestionBlocker', true);
2039
+ } catch (e) {
2040
+ console.error('[ep_data_tables:mobileSuggestionBlocker] Error inserting space:', e);
2041
+ }
2042
+ }, 0);
2043
+ };
2044
+
2045
+ // Listen in capture phase so we win the race with browser/default handlers
2046
+ if ($inner && $inner.length > 0 && $inner[0].addEventListener) {
2047
+ $inner[0].addEventListener('beforeinput', mobileSuggestionBlocker, true);
2048
+ }
2049
+
2050
+ // Fallback for Safari iOS which may not emit beforeinput reliably: use input
2051
+ if ($inner && $inner.length > 0 && $inner[0].addEventListener) {
2052
+ $inner[0].addEventListener('input', (evt) => {
2053
+ // Only run if we did NOT already block in beforeinput
2054
+ if (evt && evt.inputType === 'insertText' && typeof evt.data === 'string' && evt.data.length > 1) {
2055
+ mobileSuggestionBlocker(evt);
2056
+ }
2057
+ }, true);
2058
+ }
2059
+
2060
+ // Android legacy fallback: some keyboards dispatch 'textInput' instead of beforeinput
2061
+ if (isAndroidUA && isAndroidUA() && $inner && $inner.length > 0 && $inner[0].addEventListener) {
2062
+ $inner[0].addEventListener('textInput', (evt) => {
2063
+ const s = typeof evt.data === 'string' ? evt.data : '';
2064
+ if (s && s.length > 1) {
2065
+ mobileSuggestionBlocker({
2066
+ inputType: 'insertText',
2067
+ isComposing: false,
2068
+ data: s,
2069
+ preventDefault: () => { try { evt.preventDefault(); } catch (_) {} },
2070
+ stopImmediatePropagation: () => { try { evt.stopImmediatePropagation(); } catch (_) {} },
2071
+ });
2072
+ }
2073
+ }, true);
2074
+ }
2075
+
2076
+ // Reduce chance of autocorrect/spellcheck kicking in at all
2077
+ try {
2078
+ const disableAuto = (el) => {
2079
+ if (!el) return;
2080
+ el.setAttribute('autocorrect', 'off');
2081
+ el.setAttribute('autocomplete', 'off');
2082
+ el.setAttribute('autocapitalize', 'off');
2083
+ el.setAttribute('spellcheck', 'false');
2084
+ };
2085
+ disableAuto(innerDocBody[0] || innerDocBody);
2086
+ } catch (_) {}
1954
2087
  } catch (e) {
1955
2088
  console.error(`${callWithAceLogPrefix} ERROR: Exception while trying to find inner iframe body:`, e);
1956
2089
  // log(`${callWithAceLogPrefix} Exception details:`, { message: e.message, stack: e.stack });
@@ -2488,17 +2621,60 @@ exports.aceInitialized = (h, ctx) => {
2488
2621
  currentOffset += cellLength + DELIMITER.length;
2489
2622
  }
2490
2623
 
2491
- // Clamp selection end if it includes a trailing delimiter of the same cell
2492
- if (targetCellIndex !== -1 && selEnd[1] === cellEndCol + DELIMITER.length) {
2493
- selEnd[1] = cellEndCol;
2494
- }
2495
-
2496
2624
  // If selection spills outside the cell boundaries, block to protect structure
2497
2625
  if (targetCellIndex === -1 || selEnd[1] > cellEndCol) {
2498
2626
  evt.preventDefault();
2499
2627
  return;
2500
2628
  }
2501
2629
 
2630
+ // iOS soft break (insertParagraph/insertLineBreak) → replace with plain space via Ace
2631
+ if (inputType === 'insertParagraph' || inputType === 'insertLineBreak') {
2632
+ evt.preventDefault();
2633
+ evt.stopPropagation();
2634
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
2635
+ setTimeout(() => {
2636
+ try {
2637
+ ed.ace_callWithAce((aceInstance) => {
2638
+ aceInstance.ace_fastIncorp(10);
2639
+ const freshRep = aceInstance.ace_getRep();
2640
+ const freshSelStart = freshRep.selStart;
2641
+ const freshSelEnd = freshRep.selEnd;
2642
+ // Replace soft break with space
2643
+ aceInstance.ace_performDocumentReplaceRange(freshSelStart, freshSelEnd, ' ');
2644
+
2645
+ // Apply td attr to inserted space
2646
+ const afterRep = aceInstance.ace_getRep();
2647
+ const maxLen = Math.max(0, afterRep.lines.atIndex(lineNum)?.text?.length || 0);
2648
+ const startCol = Math.min(Math.max(freshSelStart[1], 0), maxLen);
2649
+ const endCol = Math.min(startCol + 1, maxLen);
2650
+ if (endCol > startCol) {
2651
+ aceInstance.ace_performDocumentApplyAttributesToRange(
2652
+ [lineNum, startCol], [lineNum, endCol], [[ATTR_CELL, String(targetCellIndex)]]
2653
+ );
2654
+ }
2655
+
2656
+ // Re-apply line metadata
2657
+ ed.ep_data_tables_applyMeta(
2658
+ lineNum, tableMetadata.tblId, tableMetadata.row, tableMetadata.cols,
2659
+ afterRep, ed, null, docManager
2660
+ );
2661
+
2662
+ const newCaretPos = [lineNum, endCol];
2663
+ aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2664
+ aceInstance.ace_fastIncorp(10);
2665
+ }, 'iosSoftBreakToSpace', true);
2666
+ } catch (e) {
2667
+ console.error(`${autoLogPrefix} ERROR replacing soft break:`, e);
2668
+ }
2669
+ }, 0);
2670
+ return;
2671
+ }
2672
+
2673
+ // Clamp selection end if it includes a trailing delimiter of the same cell
2674
+ if (targetCellIndex !== -1 && selEnd[1] === cellEndCol + DELIMITER.length) {
2675
+ selEnd[1] = cellEndCol;
2676
+ }
2677
+
2502
2678
  // Handle line breaks by routing to Enter behavior
2503
2679
  if (inputType === 'insertParagraph' || inputType === 'insertLineBreak') {
2504
2680
  evt.preventDefault();
@@ -2612,6 +2788,297 @@ exports.aceInitialized = (h, ctx) => {
2612
2788
  }
2613
2789
  });
2614
2790
 
2791
+ // Desktop (non-Android/iOS) – sanitize NBSP and table delimiter on any insert* beforeinput
2792
+ $inner.on('beforeinput', (evt) => {
2793
+ const genericLogPrefix = '[ep_data_tables:beforeinputInsertTextGeneric]';
2794
+ const inputType = (evt.originalEvent && evt.originalEvent.inputType) || '';
2795
+ // log diagnostics for all insert* types on table lines
2796
+ if (!inputType || !inputType.startsWith('insert')) return;
2797
+
2798
+ // Skip on Android or iOS (they have dedicated handlers above)
2799
+ if (isAndroidUA() || isIOSUA()) return;
2800
+
2801
+ const rep = ed.ace_getRep();
2802
+ if (!rep || !rep.selStart) return;
2803
+ const selStart = rep.selStart;
2804
+ const selEnd = rep.selEnd;
2805
+ const lineNum = selStart[0];
2806
+
2807
+ // Ensure we are inside a table line by resolving metadata
2808
+ let lineAttrString = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2809
+ let tableMetadata = null;
2810
+ if (lineAttrString) {
2811
+ try { tableMetadata = JSON.parse(lineAttrString); } catch (_) {}
2812
+ }
2813
+ if (!tableMetadata) tableMetadata = getTableLineMetadata(lineNum, ed, docManager);
2814
+ if (!tableMetadata || typeof tableMetadata.cols !== 'number') return; // not a table line
2815
+ console.debug(`${genericLogPrefix} event`, { inputType, data: evt.originalEvent && evt.originalEvent.data });
2816
+
2817
+ // Treat null data (composition/IME) as a single space to prevent NBSP/BR side effects
2818
+ const rawData = evt.originalEvent && typeof evt.originalEvent.data === 'string' ? evt.originalEvent.data : ' ';
2819
+
2820
+ // Replace NBSP and delimiter with plain space, remove zero-width chars
2821
+ const insertedText = rawData
2822
+ .replace(/\u00A0/g, ' ')
2823
+ .replace(new RegExp(DELIMITER, 'g'), ' ')
2824
+ .replace(/[\u200B\u200C\u200D\uFEFF]/g, '')
2825
+ .replace(/\s+/g, ' ');
2826
+
2827
+ if (!insertedText) { evt.preventDefault(); return; }
2828
+
2829
+ // Compute cell boundaries to keep selection within current cell
2830
+ const lineText = rep.lines.atIndex(lineNum)?.text || '';
2831
+ const cells = lineText.split(DELIMITER);
2832
+ let currentOffset = 0;
2833
+ let targetCellIndex = -1;
2834
+ let cellStartCol = 0;
2835
+ let cellEndCol = 0;
2836
+ for (let i = 0; i < cells.length; i++) {
2837
+ const len = cells[i]?.length ?? 0;
2838
+ const end = currentOffset + len;
2839
+ if (selStart[1] >= currentOffset && selStart[1] <= end) {
2840
+ targetCellIndex = i;
2841
+ cellStartCol = currentOffset;
2842
+ cellEndCol = end;
2843
+ break;
2844
+ }
2845
+ currentOffset += len + DELIMITER.length;
2846
+ }
2847
+ if (targetCellIndex === -1 || selEnd[1] > cellEndCol) { evt.preventDefault(); console.debug(`${genericLogPrefix} abort: selection outside cell`, { selStart, selEnd, cellStartCol, cellEndCol }); return; }
2848
+
2849
+ evt.preventDefault();
2850
+ evt.stopPropagation();
2851
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
2852
+
2853
+ try {
2854
+ ed.ace_callWithAce((ace) => {
2855
+ ace.ace_fastIncorp(10);
2856
+ const freshRep = ace.ace_getRep();
2857
+ const freshSelStart = freshRep.selStart;
2858
+ const freshSelEnd = freshRep.selEnd;
2859
+
2860
+ ace.ace_performDocumentReplaceRange(freshSelStart, freshSelEnd, insertedText);
2861
+
2862
+ // Re-apply td attribute on inserted range
2863
+ const afterRep = ace.ace_getRep();
2864
+ const lineEntry = afterRep.lines.atIndex(lineNum);
2865
+ const maxLen = lineEntry ? lineEntry.text.length : 0;
2866
+ const startCol = Math.min(Math.max(freshSelStart[1], 0), maxLen);
2867
+ const endCol = Math.min(startCol + insertedText.length, maxLen);
2868
+ if (endCol > startCol) {
2869
+ ace.ace_performDocumentApplyAttributesToRange([lineNum, startCol], [lineNum, endCol], [[ATTR_CELL, String(targetCellIndex)]]);
2870
+ }
2871
+
2872
+ // Re-apply tbljson line attribute
2873
+ ed.ep_data_tables_applyMeta(lineNum, tableMetadata.tblId, tableMetadata.row, tableMetadata.cols, afterRep, ed, null, docManager);
2874
+
2875
+ // Move caret to end of inserted text
2876
+ const newCaretPos = [lineNum, endCol];
2877
+ ace.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2878
+ }, 'tableGenericInsertText', true);
2879
+ } catch (e) {
2880
+ console.error(`${genericLogPrefix} ERROR handling generic insertText:`, e);
2881
+ }
2882
+ });
2883
+
2884
+ // iOS (all browsers) – intercept autocorrect/IME commit replacements to protect table structure
2885
+ $inner.on('beforeinput', (evt) => {
2886
+ const autoLogPrefix = '[ep_data_tables:beforeinputAutoReplaceHandler]';
2887
+ const inputType = (evt.originalEvent && evt.originalEvent.inputType) || '';
2888
+
2889
+ // Only handle on iOS family UAs
2890
+ if (!isIOSUA()) return;
2891
+
2892
+ // Get current selection and ensure we are inside a table line (define before computing hasSelection)
2893
+ const rep = ed.ace_getRep();
2894
+ if (!rep || !rep.selStart) return;
2895
+ const selStart = rep.selStart;
2896
+ const selEnd = rep.selEnd;
2897
+ const lineNum = selStart[0];
2898
+
2899
+ // Intercept replacement/IME commit types; also catch iOS insertText when it behaves like autocorrect
2900
+ const dataStr = (evt.originalEvent && typeof evt.originalEvent.data === 'string') ? evt.originalEvent.data : '';
2901
+ const hasSelection = !(selStart[0] === selEnd[0] && selStart[1] === selEnd[1]);
2902
+ const looksLikeIOSAutoReplace = inputType === 'insertText' && dataStr.length > 1; // multi-char commit (often replacement)
2903
+ const insertTextNull = inputType === 'insertText' && dataStr === '' && !hasSelection; // predictive commit with null data (often NBSP)
2904
+ const shouldHandle = INPUTTYPE_REPLACEMENT_TYPES.has(inputType) || looksLikeIOSAutoReplace || (inputType === 'insertText' && (hasSelection || insertTextNull));
2905
+ if (!shouldHandle) return;
2906
+
2907
+ // Resolve table metadata for this line
2908
+ let lineAttrString = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2909
+ let tableMetadata = null;
2910
+ if (lineAttrString) {
2911
+ try { tableMetadata = JSON.parse(lineAttrString); } catch (_) {}
2912
+ }
2913
+ if (!tableMetadata) tableMetadata = getTableLineMetadata(lineNum, ed, docManager);
2914
+ if (!tableMetadata || typeof tableMetadata.cols !== 'number' || typeof tableMetadata.tblId === 'undefined' || typeof tableMetadata.row === 'undefined') {
2915
+ return; // not a table line
2916
+ }
2917
+
2918
+ // Compute cell boundaries and target cell index
2919
+ const lineText = rep.lines.atIndex(lineNum)?.text || '';
2920
+ const cells = lineText.split(DELIMITER);
2921
+ let currentOffset = 0;
2922
+ let targetCellIndex = -1;
2923
+ let cellStartCol = 0;
2924
+ let cellEndCol = 0;
2925
+ for (let i = 0; i < cells.length; i++) {
2926
+ const cellLength = cells[i]?.length ?? 0;
2927
+ const cellEndColThisIteration = currentOffset + cellLength;
2928
+ if (selStart[1] >= currentOffset && selStart[1] <= cellEndColThisIteration) {
2929
+ targetCellIndex = i;
2930
+ cellStartCol = currentOffset;
2931
+ cellEndCol = cellEndColThisIteration;
2932
+ break;
2933
+ }
2934
+ currentOffset += cellLength + DELIMITER.length;
2935
+ }
2936
+
2937
+ // Clamp selection end if it includes a trailing delimiter of the same cell
2938
+ if (targetCellIndex !== -1 && selEnd[1] === cellEndCol + DELIMITER.length) {
2939
+ selEnd[1] = cellEndCol;
2940
+ }
2941
+
2942
+ // If selection spills outside the cell boundaries, block to protect structure
2943
+ if (targetCellIndex === -1 || selEnd[1] > cellEndCol) {
2944
+ evt.preventDefault();
2945
+ return;
2946
+ }
2947
+
2948
+ // Replacement text payload from beforeinput
2949
+ let insertedText = dataStr;
2950
+ if (!insertedText) {
2951
+ if (insertTextNull) {
2952
+ // Predictive text commit: Safari already mutated DOM (&nbsp;<br>). Block and repair via Ace.
2953
+ evt.preventDefault();
2954
+ evt.stopPropagation();
2955
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
2956
+
2957
+ setTimeout(() => {
2958
+ try {
2959
+ ed.ace_callWithAce((aceInstance) => {
2960
+ aceInstance.ace_fastIncorp(10);
2961
+ const freshRep = aceInstance.ace_getRep();
2962
+ const freshSelStart = freshRep.selStart;
2963
+ const freshSelEnd = freshRep.selEnd;
2964
+ // Insert a plain space
2965
+ aceInstance.ace_performDocumentReplaceRange(freshSelStart, freshSelEnd, ' ');
2966
+
2967
+ // Re-apply cell attribute to the single inserted char
2968
+ const afterRep = aceInstance.ace_getRep();
2969
+ const maxLen = Math.max(0, afterRep.lines.atIndex(lineNum)?.text?.length || 0);
2970
+ const startCol = Math.min(Math.max(freshSelStart[1], 0), maxLen);
2971
+ const endCol = Math.min(startCol + 1, maxLen);
2972
+ if (endCol > startCol) {
2973
+ aceInstance.ace_performDocumentApplyAttributesToRange(
2974
+ [lineNum, startCol], [lineNum, endCol], [[ATTR_CELL, String(targetCellIndex)]]
2975
+ );
2976
+ }
2977
+
2978
+ // Re-apply table metadata line attribute
2979
+ ed.ep_data_tables_applyMeta(
2980
+ lineNum, tableMetadata.tblId, tableMetadata.row, tableMetadata.cols,
2981
+ afterRep, ed, null, docManager
2982
+ );
2983
+
2984
+ // Move caret to end of inserted text
2985
+ const newCaretPos = [lineNum, endCol];
2986
+ aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2987
+ aceInstance.ace_fastIncorp(10);
2988
+ }, 'iosPredictiveCommit', true);
2989
+ } catch (e) {
2990
+ console.error(`${autoLogPrefix} ERROR fixing predictive commit:`, e);
2991
+ }
2992
+ }, 0);
2993
+ return;
2994
+ } else {
2995
+ // No payload and not special: if it's a replacement, block default to avoid DOM mutation
2996
+ if (INPUTTYPE_REPLACEMENT_TYPES.has(inputType) || hasSelection) {
2997
+ evt.preventDefault();
2998
+ evt.stopPropagation();
2999
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
3000
+ }
3001
+ return;
3002
+ }
3003
+ }
3004
+
3005
+ // Sanitize inserted text: remove our delimiter and zero-width characters; normalize whitespace
3006
+ insertedText = insertedText
3007
+ .replace(new RegExp(DELIMITER, 'g'), ' ')
3008
+ .replace(/[\u200B\u200C\u200D\uFEFF]/g, '')
3009
+ .replace(/\s+/g, ' ');
3010
+
3011
+ // Intercept the browser default and perform the edit via Ace APIs
3012
+ evt.preventDefault();
3013
+ evt.stopPropagation();
3014
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
3015
+
3016
+ try {
3017
+ setTimeout(() => {
3018
+ ed.ace_callWithAce((aceInstance) => {
3019
+ aceInstance.ace_fastIncorp(10);
3020
+ const freshRep = aceInstance.ace_getRep();
3021
+ const freshSelStart = freshRep.selStart;
3022
+ const freshSelEnd = freshRep.selEnd;
3023
+
3024
+ // Replace selection with sanitized text
3025
+ aceInstance.ace_performDocumentReplaceRange(freshSelStart, freshSelEnd, insertedText);
3026
+
3027
+ // Re-apply cell attribute to the newly inserted text (clamped to line length)
3028
+ const repAfterReplace = aceInstance.ace_getRep();
3029
+ const freshLineIndex = freshSelStart[0];
3030
+ const freshLineEntry = repAfterReplace.lines.atIndex(freshLineIndex);
3031
+ const maxLen = Math.max(0, (freshLineEntry && freshLineEntry.text) ? freshLineEntry.text.length : 0);
3032
+ const startCol = Math.min(Math.max(freshSelStart[1], 0), maxLen);
3033
+ const endColRaw = startCol + insertedText.length;
3034
+ const endCol = Math.min(endColRaw, maxLen);
3035
+ if (endCol > startCol) {
3036
+ aceInstance.ace_performDocumentApplyAttributesToRange(
3037
+ [freshLineIndex, startCol], [freshLineIndex, endCol], [[ATTR_CELL, String(targetCellIndex)]]
3038
+ );
3039
+ }
3040
+
3041
+ // Re-apply table metadata line attribute
3042
+ ed.ep_data_tables_applyMeta(
3043
+ freshLineIndex,
3044
+ tableMetadata.tblId,
3045
+ tableMetadata.row,
3046
+ tableMetadata.cols,
3047
+ repAfterReplace,
3048
+ ed,
3049
+ null,
3050
+ docManager
3051
+ );
3052
+
3053
+ // Move caret to end of inserted text and update last-click state
3054
+ const newCaretCol = endCol;
3055
+ const newCaretPos = [freshLineIndex, newCaretCol];
3056
+ aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
3057
+ aceInstance.ace_fastIncorp(10);
3058
+
3059
+ if (editor && editor.ep_data_tables_last_clicked && editor.ep_data_tables_last_clicked.tblId === tableMetadata.tblId) {
3060
+ // Recompute cellStartCol for fresh line
3061
+ const freshLineText = (freshLineEntry && freshLineEntry.text) || '';
3062
+ const freshCells = freshLineText.split(DELIMITER);
3063
+ let freshOffset = 0;
3064
+ for (let i = 0; i < targetCellIndex; i++) {
3065
+ freshOffset += (freshCells[i]?.length ?? 0) + DELIMITER.length;
3066
+ }
3067
+ const newRelativePos = newCaretCol - freshOffset;
3068
+ editor.ep_data_tables_last_clicked = {
3069
+ lineNum: freshLineIndex,
3070
+ tblId: tableMetadata.tblId,
3071
+ cellIndex: targetCellIndex,
3072
+ relativePos: newRelativePos < 0 ? 0 : newRelativePos,
3073
+ };
3074
+ }
3075
+ }, 'tableAutoReplaceTextOperations', true);
3076
+ }, 0);
3077
+ } catch (error) {
3078
+ console.error(`${autoLogPrefix} ERROR during auto-replace handling:`, error);
3079
+ }
3080
+ });
3081
+
2615
3082
  // Composition start marker (Android Chrome/table only)
2616
3083
  $inner.on('compositionstart', (evt) => {
2617
3084
  if (!isAndroidUA()) return;
@@ -2626,7 +3093,6 @@ exports.aceInitialized = (h, ctx) => {
2626
3093
  handledCurrentComposition = false;
2627
3094
  suppressBeforeInputInsertTextDuringComposition = false;
2628
3095
  });
2629
-
2630
3096
  // Android Chrome composition handling for whitespace (space) to prevent DOM mutation breaking delimiters
2631
3097
  $inner.on('compositionupdate', (evt) => {
2632
3098
  const compLogPrefix = '[ep_data_tables:compositionHandler]';
@@ -4122,7 +4588,6 @@ exports.aceInitialized = (h, ctx) => {
4122
4588
  return false;
4123
4589
  }
4124
4590
  }
4125
-
4126
4591
  function deleteTableColumnWithText(tableLines, targetColIndex, editorInfo, docManager) {
4127
4592
  try {
4128
4593
  // Update text content for all table lines using precise character deletion