ep_data_tables 0.0.97 → 0.0.99

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.97",
3
+ "version": "0.0.99",
4
4
  "description": "BETA - etherpad tables plugin, compatible with other character/line based styling and other features",
5
5
  "author": {
6
6
  "name": "DCastelone",
@@ -741,807 +741,21 @@ exports.acePostWriteDomLineHTML = function (hook_name, args, cb) {
741
741
  let skipMismatchReturn = false;
742
742
 
743
743
  if (htmlSegments.length !== rowMetadata.cols) {
744
- console.warn('[ep_data_tables][diag] Segment/column mismatch', { nodeId, lineNum, segs: htmlSegments.length, cols: rowMetadata.cols, tblId: rowMetadata.tblId, row: rowMetadata.row });
745
-
746
- // Skip mismatch repair during column operations
747
- if (__epDT_columnOperationInProgress) {
748
- console.debug('[ep_data_tables][diag] mismatch-skip-scheduling', { nodeId, reason: 'column-operation-in-progress' });
749
- skipMismatchReturn = true;
750
- }
751
- // Schedule deferred canonicalization
752
- else if (nodeId && !__epDT_postWriteScheduled.has(nodeId)) {
753
- __epDT_postWriteScheduled.add(nodeId);
754
- const fallbackLineNum = lineNum; // DOM-derived index as fallback
755
- const capturedTblId = rowMetadata.tblId;
756
- const capturedRow = rowMetadata.row;
757
- const expectedCols = rowMetadata.cols;
758
- setTimeout(() => {
759
- try {
760
- if (!EP_DT_EDITOR_INFO) return;
761
- const ed = EP_DT_EDITOR_INFO;
762
- const docManager = ed.ep_data_tables_docManager || null;
763
-
764
- if (__epDT_columnOperationInProgress) {
765
- console.debug('[ep_data_tables:postWriteCanonicalize] skipped - column operation in progress');
766
- return;
767
- }
768
-
769
- // Pre-check editor state (prevents crash if keyToNodeMap corrupted)
770
- try {
771
- const preCheckRep = ed.ace_getRep && ed.ace_getRep();
772
- if (!preCheckRep || !preCheckRep.lines) {
773
- console.debug('[ep_data_tables:postWriteCanonicalize] skipped - rep unavailable');
774
- return;
775
- }
776
- } catch (preCheckErr) {
777
- console.debug('[ep_data_tables:postWriteCanonicalize] skipped - pre-check failed', preCheckErr?.message);
778
- return;
779
- }
780
-
781
- try {
782
- ed.ace_callWithAce((ace) => {
783
- try {
784
- const rep = ace.ace_getRep();
785
- if (!rep || !rep.lines) return;
786
-
787
- const origLineInfo = __epDT_compositionOriginalLine;
788
- const hasOriginalLineInfo = origLineInfo &&
789
- origLineInfo.tblId === capturedTblId &&
790
- typeof origLineInfo.lineNum === 'number' &&
791
- (Date.now() - origLineInfo.timestamp) < 5000; // Only use if recent (within 5 seconds)
792
-
793
- console.debug('[ep_data_tables:postWriteCanonicalize] start', {
794
- nodeId, fallbackLineNum, capturedTblId, capturedRow, expectedCols,
795
- hasDocManager: !!docManager,
796
- originalLineInfo: hasOriginalLineInfo ? origLineInfo.lineNum : null,
797
- });
798
- if (!docManager || typeof docManager.getAttributeOnLine !== 'function') {
799
- console.debug('[ep_data_tables:postWriteCanonicalize] abort: no documentAttributeManager available');
800
- return;
801
- }
802
- // Robust line resolution: primary by attribute scan (tblId/row), then key lookup, then fallback.
803
- let ln = -1;
804
- let attrScanLine = -1; // Track where attr scan finds the table (may differ from original)
805
- try {
806
- const totalScan = rep.lines.length();
807
- for (let i = 0; i < totalScan; i++) {
808
- let sAttr = null;
809
- try { sAttr = docManager.getAttributeOnLine(i, ATTR_TABLE_JSON); } catch (_) {}
810
- if (!sAttr) continue;
811
- try {
812
- const m = JSON.parse(sAttr);
813
- if (m && m.tblId === capturedTblId && m.row === capturedRow) {
814
- attrScanLine = i;
815
- ln = i;
816
- console.debug('[ep_data_tables:postWriteCanonicalize] found-by-attr-scan', { ln, tblId: m.tblId, row: m.row, cols: m.cols });
817
- break;
818
- }
819
- } catch (_) { /* ignore parse errors */ }
820
- }
821
- } catch (scanErr) {
822
- console.error('[ep_data_tables:postWriteCanonicalize] line scan error', scanErr);
823
- }
824
-
825
- if (hasOriginalLineInfo && attrScanLine >= 0 && attrScanLine !== origLineInfo.lineNum) {
826
- console.debug('[ep_data_tables:postWriteCanonicalize] line shift detected', {
827
- attrScanLine,
828
- originalLine: origLineInfo.lineNum,
829
- shift: attrScanLine - origLineInfo.lineNum,
830
- });
831
-
832
- // Check if the original line still has a table DOM
833
- const origEntry = rep.lines.atIndex(origLineInfo.lineNum);
834
- if (origEntry?.lineNode) {
835
- const origTableEl = origEntry.lineNode.querySelector(
836
- `table.dataTable[data-tblId="${capturedTblId}"], table.dataTable[data-tblid="${capturedTblId}"]`
837
- );
838
- if (origTableEl) {
839
- console.debug('[ep_data_tables:postWriteCanonicalize] original line still has table DOM, preferring it', {
840
- originalLine: origLineInfo.lineNum,
841
- attrLine: attrScanLine,
842
- });
843
- ln = origLineInfo.lineNum;
844
- }
845
- }
846
- }
847
-
848
- if (ln < 0) {
849
- try {
850
- if (rep.lines && typeof rep.lines.indexOfKey === 'function') {
851
- ln = rep.lines.indexOfKey(nodeId);
852
- console.debug('[ep_data_tables:postWriteCanonicalize] found-by-indexOfKey', { ln, nodeId });
853
- }
854
- } catch (eIdx) {
855
- console.error('[ep_data_tables:postWriteCanonicalize] indexOfKey error; using fallback line', eIdx);
856
- ln = (typeof fallbackLineNum === 'number') ? fallbackLineNum : -1;
857
- }
858
- }
859
- if (typeof ln !== 'number' || ln < 0) {
860
- console.debug('[ep_data_tables:postWriteCanonicalize] abort: invalid line', { nodeId, ln });
861
- return;
862
- }
863
- let attrStr = null;
864
- try { attrStr = docManager.getAttributeOnLine(ln, ATTR_TABLE_JSON); } catch (_) { attrStr = null; }
865
- if (!attrStr) {
866
- console.debug('[ep_data_tables:postWriteCanonicalize] abort: no tbljson on resolved line', { ln, nodeId, capturedTblId, capturedRow });
867
- return;
868
- }
869
- let metaAttr = null;
870
- try { metaAttr = JSON.parse(attrStr); } catch (_) { metaAttr = null; }
871
- if (!metaAttr || metaAttr.tblId !== capturedTblId || metaAttr.row !== capturedRow) {
872
- console.debug('[ep_data_tables:postWriteCanonicalize] abort: meta mismatch', { ln, metaAttr, capturedTblId, capturedRow });
873
- return;
874
- }
875
- if (typeof metaAttr.cols !== 'number') {
876
- console.debug('[ep_data_tables:postWriteCanonicalize] abort: invalid cols', { ln, metaAttr });
877
- return;
878
- }
879
- const entry = rep.lines.atIndex(ln);
880
- const currentText = entry?.text || '';
881
- const segs = currentText.split(DELIMITER);
882
- const needed = metaAttr.cols;
883
- const sanitize = (s) => {
884
- const x = normalizeSoftWhitespace((s || '').replace(new RegExp(DELIMITER, 'g'), ' ').replace(/[\u200B\u200C\u200D\uFEFF]/g, ' '));
885
- return x || ' ';
886
- };
887
- const cells = new Array(needed);
888
- for (let i = 0; i < needed; i++) cells[i] = sanitize(segs[i] || ' ');
889
- const canonical = cells.join(DELIMITER);
890
-
891
- // Check if line has table DOM (text matching alone is insufficient)
892
- let lineHasTableDOM = false;
893
- try {
894
- const lineNode = entry?.lineNode;
895
- if (lineNode && typeof lineNode.querySelector === 'function') {
896
- const tableEl = lineNode.querySelector(
897
- `table.dataTable[data-tblId="${capturedTblId}"], table.dataTable[data-tblid="${capturedTblId}"]`
898
- );
899
- lineHasTableDOM = !!tableEl;
900
- }
901
- } catch (_) {}
902
-
903
- const textMatches = canonical === currentText;
904
- const needsRepair = !textMatches || !lineHasTableDOM;
905
-
906
- // Extract character-level styling before replacement
907
- const extractedStyling = [];
908
- try {
909
- const lineNode = entry?.lineNode;
910
- if (lineNode && lineHasTableDOM) {
911
- const tableEl = lineNode.querySelector('table.dataTable');
912
- if (tableEl) {
913
- const tds = tableEl.querySelectorAll('td');
914
- tds.forEach((td, cellIdx) => {
915
- const spans = td.querySelectorAll('span:not(.ep-data_tables-delim):not(.ep-data_tables-caret-anchor)');
916
- let relPos = 0; // Position relative to cell start
917
- spans.forEach((span) => {
918
- const text = (span.textContent || '').replace(/\u00A0/g, ' '); // Normalize nbsp
919
- const textLen = text.length;
920
- if (textLen === 0) return;
921
-
922
- // Extract ALL classes except table-related ones
923
- // Convert class names to Etherpad attribute format
924
- const stylingAttrs = [];
925
- if (span.classList) {
926
- for (const cls of span.classList) {
927
- if (cls.startsWith('tbljson-') || cls.startsWith('tblCell-')) continue;
928
- if (cls === 'ace-line' || cls.startsWith('ep-data_tables-')) continue;
929
-
930
- // Parse class name to attribute key-value pair
931
- // Etherpad uses formats like: "author-xyz", "font-size:12", "bold"
932
- if (cls.includes(':')) {
933
- // Format: "key:value" (e.g., "font-size:12", "color:red")
934
- const colonIdx = cls.indexOf(':');
935
- const key = cls.substring(0, colonIdx);
936
- const value = cls.substring(colonIdx + 1);
937
- stylingAttrs.push([key, value]);
938
- } else if (cls.includes('-')) {
939
- // Format: "key-value" (e.g., "author-a1b2c3")
940
- const dashIdx = cls.indexOf('-');
941
- const key = cls.substring(0, dashIdx);
942
- const value = cls.substring(dashIdx + 1);
943
- stylingAttrs.push([key, value]);
944
- } else {
945
- // Format: "key" only (e.g., "bold", "italic")
946
- // These are boolean attributes set to "true"
947
- stylingAttrs.push([cls, 'true']);
948
- }
949
- }
950
- }
951
-
952
- if (stylingAttrs.length > 0) {
953
- extractedStyling.push({
954
- cellIdx,
955
- relStart: relPos,
956
- len: textLen,
957
- text: text, // Store text for matching
958
- attrs: stylingAttrs,
959
- });
960
- }
961
- relPos += textLen;
962
- });
963
- });
964
- }
965
- }
966
- if (extractedStyling.length > 0) {
967
- console.debug('[ep_data_tables:postWriteCanonicalize] extracted styling', {
968
- ln, count: extractedStyling.length,
969
- sample: extractedStyling.slice(0, 2).map(s => ({ cell: s.cellIdx, attrs: s.attrs })),
970
- });
971
- }
972
- } catch (extractErr) {
973
- console.debug('[ep_data_tables:postWriteCanonicalize] styling extraction error (non-fatal)', extractErr?.message);
974
- }
975
-
976
- if (needsRepair) {
977
- console.debug('[ep_data_tables:postWriteCanonicalize] line needs repair', {
978
- ln,
979
- textMatches,
980
- lineHasTableDOM,
981
- fromLen: currentText.length,
982
- toLen: canonical.length,
983
- });
984
- if (!textMatches) {
985
- // Validate line is safe to modify before replacement (prevents keyToNodeMap errors)
986
- const repBeforeRepair = ace.ace_getRep();
987
- if (!isLineSafeToModify(repBeforeRepair, ln, '[ep_data_tables:postWriteCanonicalize] repair')) {
988
- console.warn('[ep_data_tables:postWriteCanonicalize] line not safe to modify, skipping repair');
989
- return cb();
990
- }
991
- ace.ace_performDocumentReplaceRange([ln, 0], [ln, currentText.length], canonical);
992
- }
993
- // Note: If lineHasTableDOM is false, orphan detection below will handle finding the real table
994
- } else {
995
- console.debug('[ep_data_tables:postWriteCanonicalize] line already canonical', { ln, lineHasTableDOM });
996
- }
997
- let offset = 0;
998
- for (let i = 0; i < cells.length; i++) {
999
- const len = cells[i].length;
1000
- if (len > 0) {
1001
- ace.ace_performDocumentApplyAttributesToRange([ln, offset], [ln, offset + len], [[ATTR_CELL, String(i)]]);
1002
- }
1003
- offset += len;
1004
- if (i < cells.length - 1) offset += DELIMITER.length;
1005
- }
1006
-
1007
- if (extractedStyling.length > 0) {
1008
- let appliedCount = 0;
1009
- try {
1010
- for (const style of extractedStyling) {
1011
- const cellIdx = style.cellIdx;
1012
- const cellContent = cells[cellIdx] || '';
1013
- const styledText = style.text;
1014
-
1015
- if (!styledText || styledText.length === 0 || !cellContent) continue;
1016
-
1017
- // Find where the styled text appears in the NEW cell content
1018
- let foundPos = cellContent.indexOf(styledText);
1019
-
1020
- if (foundPos === -1) {
1021
- const lowerCell = cellContent.toLowerCase();
1022
- const lowerStyled = styledText.toLowerCase();
1023
- foundPos = lowerCell.indexOf(lowerStyled);
1024
- }
1025
-
1026
- if (foundPos === -1) continue; // Text not found in cell
1027
-
1028
- // Calculate absolute position in the line
1029
- let absStart = 0;
1030
- for (let c = 0; c < cellIdx; c++) {
1031
- absStart += (cells[c]?.length || 0) + DELIMITER.length;
1032
- }
1033
- absStart += foundPos;
1034
- const absEnd = absStart + styledText.length;
1035
-
1036
- if (style.attrs.length > 0) {
1037
- ace.ace_performDocumentApplyAttributesToRange(
1038
- [ln, absStart],
1039
- [ln, absEnd],
1040
- style.attrs
1041
- );
1042
- appliedCount++;
1043
- }
1044
- }
1045
- if (appliedCount > 0) {
1046
- console.debug('[ep_data_tables:postWriteCanonicalize] re-applied styling', {
1047
- ln, applied: appliedCount, total: extractedStyling.length,
1048
- });
1049
- }
1050
- } catch (applyErr) {
1051
- console.debug('[ep_data_tables:postWriteCanonicalize] styling re-apply error (non-fatal)', applyErr?.message);
1052
- }
1053
- }
1054
-
1055
- try {
1056
- ed.ep_data_tables_applyMeta(ln, metaAttr.tblId, metaAttr.row, metaAttr.cols, ace.ace_getRep(), ed, JSON.stringify(metaAttr), docManager);
1057
- } catch (metaErr) {
1058
- console.error('[ep_data_tables:postWriteCanonicalize] meta apply error', metaErr);
1059
- }
1060
-
1061
- if (!lineHasTableDOM) {
1062
- console.debug('[ep_data_tables:postWriteCanonicalize] skipping orphan detection (no table on primary line)', { ln, capturedTblId, capturedRow });
1063
- return;
1064
- }
1065
-
1066
- // Detect orphan lines (same tblId/row or tbljson-* spans without table)
1067
- const orphanLines = [];
1068
- const seenOrphanLines = new Set();
1069
- try {
1070
- const total = rep.lines.length();
1071
- for (let li = 0; li < total; li++) {
1072
- if (li === ln) continue;
1073
-
1074
- // Check line attribute
1075
- let sOther = null;
1076
- try { sOther = docManager.getAttributeOnLine(li, ATTR_TABLE_JSON); } catch (_) { sOther = null; }
1077
- if (sOther) {
1078
- let mOther = null;
1079
- try { mOther = JSON.parse(sOther); } catch (_) { mOther = null; }
1080
- if (mOther && mOther.tblId === capturedTblId && mOther.row === capturedRow) {
1081
- const orphanEntry = rep.lines.atIndex(li);
1082
- const orphanText = orphanEntry?.text || '';
1083
- orphanLines.push({ lineNum: li, text: orphanText, meta: mOther, source: 'attr' });
1084
- seenOrphanLines.add(li);
1085
- continue;
1086
- }
1087
- }
1088
-
1089
- // DOM-based detection (tbljson-* or tblCell-* spans without table parent)
1090
- if (seenOrphanLines.has(li)) continue;
1091
- try {
1092
- const lineEntry = rep.lines.atIndex(li);
1093
- const lineNode = lineEntry?.lineNode;
1094
- if (lineNode) {
1095
- const hasTable = lineNode.querySelector('table.dataTable');
1096
- if (!hasTable) {
1097
- const tbljsonSpan = lineNode.querySelector('[class*="tbljson-"]');
1098
- if (tbljsonSpan) {
1099
- for (const cls of tbljsonSpan.classList) {
1100
- if (cls.startsWith('tbljson-')) {
1101
- try {
1102
- const decoded = JSON.parse(atob(cls.substring(8)));
1103
- if (decoded && decoded.tblId === capturedTblId && decoded.row === capturedRow) {
1104
- const orphanText = lineEntry?.text || '';
1105
- orphanLines.push({
1106
- lineNum: li,
1107
- text: orphanText,
1108
- meta: decoded,
1109
- source: 'dom-class-tbljson',
1110
- });
1111
- seenOrphanLines.add(li);
1112
- console.debug('[ep_data_tables:postWriteCanonicalize] detected tbljson DOM orphan', {
1113
- lineNum: li, tblId: decoded.tblId, row: decoded.row, textLen: orphanText.length,
1114
- });
1115
- break;
1116
- }
1117
- } catch (_) {}
1118
- }
1119
- }
1120
- }
1121
-
1122
- // Also check for tblCell-* spans (no encoded metadata)
1123
- if (!seenOrphanLines.has(li)) {
1124
- const tblCellSpan = lineNode.querySelector('[class*="tblCell-"]');
1125
- if (tblCellSpan) {
1126
- // SAFETY CHECK: Verify this line has OUR tbljson attribute, just row might differ
1127
- let belongsToOurTable = false;
1128
- try {
1129
- const lineAttr = docManager.getAttributeOnLine(li, ATTR_TABLE_JSON);
1130
- if (lineAttr) {
1131
- const lineMeta = JSON.parse(lineAttr);
1132
- if (lineMeta && lineMeta.tblId === capturedTblId) {
1133
- belongsToOurTable = true;
1134
- }
1135
- } else {
1136
- // No tbljson attribute - check if there's a tbljson-* CLASS that we can decode
1137
- const anyTbljsonSpan = lineNode.querySelector('[class*="tbljson-"]');
1138
- if (anyTbljsonSpan) {
1139
- for (const cls of anyTbljsonSpan.classList) {
1140
- if (cls.startsWith('tbljson-')) {
1141
- try {
1142
- const decoded = JSON.parse(atob(cls.substring(8)));
1143
- if (decoded && decoded.tblId === capturedTblId) {
1144
- belongsToOurTable = true;
1145
- }
1146
- } catch (_) {}
1147
- break;
1148
- }
1149
- }
1150
- }
1151
- }
1152
- } catch (_) {}
1153
-
1154
- if (belongsToOurTable) {
1155
- const orphanText = lineEntry?.text || '';
1156
- if (orphanText.includes(DELIMITER) || orphanText.trim().length > 0) {
1157
- orphanLines.push({
1158
- lineNum: li,
1159
- text: orphanText,
1160
- meta: { tblId: capturedTblId, row: capturedRow, cols: expectedCols },
1161
- source: 'dom-class-tblCell',
1162
- });
1163
- seenOrphanLines.add(li);
1164
- console.debug('[ep_data_tables:postWriteCanonicalize] detected tblCell DOM orphan', {
1165
- lineNum: li, capturedTblId, capturedRow, textLen: orphanText.length,
1166
- });
1167
- }
1168
- } else {
1169
- console.debug('[ep_data_tables:postWriteCanonicalize] skipping tblCell line (different table)', {
1170
- lineNum: li, capturedTblId,
1171
- });
1172
- }
1173
- }
1174
- }
1175
- }
1176
- }
1177
- } catch (domErr) {
1178
- console.error('[ep_data_tables:postWriteCanonicalize] DOM orphan detection error', domErr);
1179
- }
1180
- }
1181
- } catch (scanOrphanErr) {
1182
- console.error('[ep_data_tables:postWriteCanonicalize] orphan scan error', scanOrphanErr);
1183
- }
1184
-
1185
- if (orphanLines.length > 0) {
1186
- let mainEntry = rep.lines.atIndex(ln);
1187
-
1188
- let lineWithTableIdx = -1;
1189
- try {
1190
- const innerDoc = node?.ownerDocument || document;
1191
- const editorBody = innerDoc.getElementById('innerdocbody') || innerDoc.body;
1192
- if (editorBody) {
1193
- const allAceLines = editorBody.querySelectorAll('div.ace-line');
1194
- for (let ai = 0; ai < allAceLines.length; ai++) {
1195
- const aceLine = allAceLines[ai];
1196
- const tableEl = aceLine.querySelector(
1197
- `table.dataTable[data-tblId="${capturedTblId}"][data-row="${capturedRow}"], ` +
1198
- `table.dataTable[data-tblid="${capturedTblId}"][data-row="${capturedRow}"]`
1199
- );
1200
- if (tableEl) {
1201
- lineWithTableIdx = ai;
1202
- console.debug('[ep_data_tables:postWriteCanonicalize] LIVE DOM found table', {
1203
- domIndex: ai, aceLineId: aceLine.id, tblId: capturedTblId, row: capturedRow,
1204
- });
1205
- break;
1206
- }
1207
- }
1208
- }
1209
- } catch (domQueryErr) {
1210
- console.error('[ep_data_tables:postWriteCanonicalize] live DOM query error', domQueryErr);
1211
- }
1212
-
1213
- if (lineWithTableIdx >= 0 && lineWithTableIdx !== ln) {
1214
- // Find if this line is in our orphan list
1215
- const orphanIdx = orphanLines.findIndex(o => o.lineNum === lineWithTableIdx);
1216
- if (orphanIdx >= 0) {
1217
- console.warn('[ep_data_tables:postWriteCanonicalize] SWAPPING primary via LIVE DOM: table found on orphan line', {
1218
- oldPrimary: ln,
1219
- newPrimary: lineWithTableIdx,
1220
- orphanIdx,
1221
- });
1222
-
1223
- // Move current "primary" to orphan list
1224
- const formerPrimary = {
1225
- lineNum: ln,
1226
- text: mainEntry?.text || '',
1227
- meta: metaAttr,
1228
- source: 'swapped-to-orphan',
1229
- };
1230
-
1231
- // Swap: orphan becomes primary
1232
- const newPrimary = orphanLines[orphanIdx];
1233
- ln = newPrimary.lineNum;
1234
- metaAttr = newPrimary.meta;
1235
- mainEntry = rep.lines.atIndex(ln);
1236
-
1237
- orphanLines.splice(orphanIdx, 1);
1238
- orphanLines.push(formerPrimary);
1239
-
1240
- const newText = mainEntry?.text || '';
1241
- const newSegs = newText.split(DELIMITER);
1242
- for (let i = 0; i < needed; i++) {
1243
- cells[i] = sanitize(newSegs[i] || ' ');
1244
- }
1245
- }
1246
- } else if (lineWithTableIdx < 0) {
1247
- // No line has the table - this is bad, but we should still try to preserve content
1248
- console.warn('[ep_data_tables:postWriteCanonicalize] WARNING: no line has table DOM for tblId/row', {
1249
- capturedTblId, capturedRow, primaryLn: ln, orphanCount: orphanLines.length,
1250
- });
1251
- }
1252
-
1253
- console.warn('[ep_data_tables:postWriteCanonicalize] orphan lines detected - merging content', {
1254
- keepLine: ln, orphans: orphanLines.map(o => ({ line: o.lineNum, textLen: o.text.length })),
1255
- tableFoundOnLine: lineWithTableIdx,
1256
- });
1257
-
1258
- // CRITICAL FIX: Extract cell content from LIVE DOM table, not from line text!
1259
- const mainText = mainEntry?.text || '';
1260
- const mainSegs = mainText.split(DELIMITER);
1261
- const mergedCells = new Array(needed);
1262
-
1263
- // Try to get clean cell content from the DOM table
1264
- let usedDomContent = false;
1265
- if (lineWithTableIdx >= 0) {
1266
- try {
1267
- const innerDoc = node?.ownerDocument || document;
1268
- const editorBody = innerDoc.getElementById('innerdocbody') || innerDoc.body;
1269
- const allAceLines = editorBody?.querySelectorAll('div.ace-line');
1270
- const tableAceLine = allAceLines?.[lineWithTableIdx];
1271
- const tableEl = tableAceLine?.querySelector(
1272
- `table.dataTable[data-tblId="${capturedTblId}"], table.dataTable[data-tblid="${capturedTblId}"]`
1273
- );
1274
- if (tableEl) {
1275
- const tr = tableEl.querySelector('tbody > tr');
1276
- if (tr && tr.children.length === needed) {
1277
- for (let i = 0; i < needed; i++) {
1278
- const td = tr.children[i];
1279
- // Extract text content, excluding delimiters and special elements
1280
- let cellText = '';
1281
- for (const child of td.childNodes) {
1282
- if (child.nodeType === 3) { // Text node
1283
- cellText += child.textContent || '';
1284
- } else if (child.nodeType === 1) { // Element
1285
- const el = child;
1286
- if (el.classList?.contains('ep-data_tables-delim')) continue;
1287
- if (el.classList?.contains('ep-data_tables-caret-anchor')) continue;
1288
- if (el.classList?.contains('ep-data_tables-resize-handle')) continue;
1289
- cellText += el.textContent || '';
1290
- }
1291
- }
1292
- mergedCells[i] = sanitize(cellText || ' ');
1293
- }
1294
- usedDomContent = true;
1295
- console.debug('[ep_data_tables:postWriteCanonicalize] using DOM table content for merge base', {
1296
- cells: mergedCells.map(c => c.slice(0, 20)),
1297
- });
1298
- }
1299
- }
1300
- } catch (domExtractErr) {
1301
- console.error('[ep_data_tables:postWriteCanonicalize] DOM content extraction error', domExtractErr);
1302
- }
1303
- }
1304
-
1305
- if (!usedDomContent) {
1306
- for (let i = 0; i < needed; i++) {
1307
- mergedCells[i] = sanitize(mainSegs[i] || ' ');
1308
- }
1309
- console.debug('[ep_data_tables:postWriteCanonicalize] using line text for merge base (DOM extraction failed)');
1310
- }
1311
-
1312
- for (const orphan of orphanLines) {
1313
- const orphanSegs = orphan.text.split(DELIMITER);
1314
- for (let i = 0; i < Math.min(orphanSegs.length, needed); i++) {
1315
- const orphanContent = sanitize(orphanSegs[i] || '');
1316
- if (!orphanContent || orphanContent.trim() === '') continue;
1317
- const mainCellTrimmed = (mergedCells[i] || '').trim();
1318
- const orphanTrimmed = orphanContent.trim();
1319
- if (mainCellTrimmed.includes(orphanTrimmed)) continue;
1320
- if (!mainCellTrimmed) {
1321
- mergedCells[i] = orphanContent;
1322
- } else {
1323
- // Append orphan content to existing cell content (preserve user data)
1324
- mergedCells[i] = mainCellTrimmed + orphanTrimmed;
1325
- }
1326
- console.debug('[ep_data_tables:postWriteCanonicalize] merged orphan content', {
1327
- cellIdx: i, orphanLine: orphan.lineNum, orphanContent, mergedResult: mergedCells[i],
1328
- });
1329
- }
1330
- }
1331
-
1332
- const repBeforeMerge = ace.ace_getRep();
1333
- if (!isLineSafeToModify(repBeforeMerge, ln, '[ep_data_tables:postWriteCanonicalize] merge-target')) {
1334
- console.warn('[ep_data_tables:postWriteCanonicalize] main line not safe to modify, aborting merge');
1335
- } else {
1336
- const mergedCanonical = mergedCells.join(DELIMITER);
1337
- if (mergedCanonical !== mainText) {
1338
- console.debug('[ep_data_tables:postWriteCanonicalize] applying merged canonical line', {
1339
- ln, fromLen: mainText.length, toLen: mergedCanonical.length, mergedCells,
1340
- });
1341
- ace.ace_performDocumentReplaceRange([ln, 0], [ln, mainText.length], mergedCanonical);
1342
-
1343
- let mergeOffset = 0;
1344
- for (let i = 0; i < mergedCells.length; i++) {
1345
- const cellLen = mergedCells[i].length;
1346
- if (cellLen > 0) {
1347
- ace.ace_performDocumentApplyAttributesToRange([ln, mergeOffset], [ln, mergeOffset + cellLen], [[ATTR_CELL, String(i)]]);
1348
- }
1349
- mergeOffset += cellLen;
1350
- if (i < mergedCells.length - 1) mergeOffset += DELIMITER.length;
1351
- }
1352
-
1353
- try {
1354
- const repAfterMerge = ace.ace_getRep();
1355
- ed.ep_data_tables_applyMeta(ln, capturedTblId, capturedRow, expectedCols, repAfterMerge, ed, JSON.stringify(metaAttr), docManager);
1356
- console.debug('[ep_data_tables:postWriteCanonicalize] re-applied tbljson after merge', {
1357
- ln, tblId: capturedTblId, row: capturedRow, cols: expectedCols,
1358
- });
1359
- } catch (metaMergeErr) {
1360
- console.error('[ep_data_tables:postWriteCanonicalize] failed to re-apply tbljson after merge', metaMergeErr);
1361
- }
1362
- }
1363
- } // end isLineSafeToModify check for merge target
1364
-
1365
- // Delete orphan lines bottom-up
1366
- // Validate lines and check DOM desync before destructive ops
1367
- if (!isDestructiveOperationSafe('postWriteCanonicalize orphan removal')) {
1368
- console.debug('[ep_data_tables:postWriteCanonicalize] skipping orphan removal (safe mode)');
1369
- } else {
1370
- orphanLines.sort((a, b) => b.lineNum - a.lineNum).forEach((orphan) => {
1371
- try {
1372
- // Re-fetch rep to get current state after any previous deletions
1373
- const repCheck = ace.ace_getRep();
1374
-
1375
- if (!isLineSafeToModify(repCheck, orphan.lineNum, '[ep_data_tables:postWriteCanonicalize] orphan')) {
1376
- return;
1377
- }
1378
-
1379
- try {
1380
- if (docManager && typeof docManager.removeAttributeOnLine === 'function') {
1381
- docManager.removeAttributeOnLine(orphan.lineNum, ATTR_TABLE_JSON);
1382
- }
1383
- } catch (remErr) {
1384
- console.debug('[ep_data_tables:postWriteCanonicalize] removeAttributeOnLine error (non-fatal)', remErr?.message);
1385
- }
1386
- console.debug('[ep_data_tables:postWriteCanonicalize] removing orphan line (content already merged)', {
1387
- orphanLine: orphan.lineNum,
1388
- });
1389
- ace.ace_performDocumentReplaceRange([orphan.lineNum, 0], [orphan.lineNum + 1, 0], '');
1390
- } catch (orphanRemErr) {
1391
- console.error('[ep_data_tables:postWriteCanonicalize] orphan line removal error', {
1392
- orphanLine: orphan.lineNum,
1393
- error: orphanRemErr?.message || orphanRemErr,
1394
- });
1395
- // Check if this was a desync error
1396
- handleDomDesyncError(orphanRemErr, 'postWriteCanonicalize orphan removal');
1397
- }
1398
- });
1399
- }
1400
-
1401
- // Clean up spurious blank lines between this table row and the next
1402
- // These can be created when orphan content gets merged and lines shift
1403
- try {
1404
- const repAfterOrphanRemoval = ace.ace_getRep();
1405
- const currentLineNum = ln;
1406
- const nextRowNum = capturedRow + 1;
1407
-
1408
- let nextRowLineNum = -1;
1409
- const totalAfter = repAfterOrphanRemoval.lines.length();
1410
- for (let li = currentLineNum + 1; li < totalAfter && li < currentLineNum + 10; li++) {
1411
- try {
1412
- const attrStr = docManager.getAttributeOnLine(li, ATTR_TABLE_JSON);
1413
- if (attrStr) {
1414
- const meta = JSON.parse(attrStr);
1415
- if (meta && meta.tblId === capturedTblId && meta.row === nextRowNum) {
1416
- nextRowLineNum = li;
1417
- break;
1418
- }
1419
- }
1420
- } catch (_) {}
1421
- }
1422
-
1423
- // If there are blank lines between current row and next row, remove them
1424
- if (nextRowLineNum > currentLineNum + 1) {
1425
- const blankLinesToRemove = [];
1426
- for (let li = currentLineNum + 1; li < nextRowLineNum; li++) {
1427
- const lineEntry = repAfterOrphanRemoval.lines.atIndex(li);
1428
- const lineText = lineEntry?.text || '';
1429
- // Check if line is blank (empty or just whitespace)
1430
- if (!lineText.trim() || lineText === '\n') {
1431
- blankLinesToRemove.push(li);
1432
- }
1433
- }
1434
-
1435
- // Remove blank lines bottom-up to preserve line numbers
1436
- if (!isDestructiveOperationSafe('postWriteCanonicalize blank line removal')) {
1437
- console.debug('[ep_data_tables:postWriteCanonicalize] skipping blank line removal (safe mode)');
1438
- } else {
1439
- blankLinesToRemove.sort((a, b) => b - a).forEach((blankLineNum) => {
1440
- try {
1441
- // Re-fetch rep and validate line before removal (prevents keyToNodeMap errors)
1442
- const repBlankCheck = ace.ace_getRep();
1443
- if (!isLineSafeToModify(repBlankCheck, blankLineNum, '[ep_data_tables:postWriteCanonicalize] blank')) {
1444
- return;
1445
- }
1446
- console.debug('[ep_data_tables:postWriteCanonicalize] removing spurious blank line between table rows', {
1447
- blankLineNum, betweenRows: [capturedRow, nextRowNum],
1448
- });
1449
- ace.ace_performDocumentReplaceRange([blankLineNum, 0], [blankLineNum + 1, 0], '');
1450
- } catch (blankRemErr) {
1451
- console.error('[ep_data_tables:postWriteCanonicalize] blank line removal error', blankRemErr);
1452
- handleDomDesyncError(blankRemErr, 'postWriteCanonicalize blank removal');
1453
- }
1454
- });
1455
- }
1456
- }
1457
- } catch (cleanupErr) {
1458
- console.error('[ep_data_tables:postWriteCanonicalize] blank line cleanup error', cleanupErr);
1459
- }
1460
-
1461
- try { ace.ace_fastIncorp(5); } catch (_) {}
1462
- }
1463
-
1464
- // ALWAYS run blank line cleanup after repair, even if there were no orphans
1465
- // Grammarly and other extensions can create blank lines without triggering orphan detection
1466
- try {
1467
- const repFinal = ace.ace_getRep();
1468
- if (repFinal && repFinal.lines) {
1469
- const finalLineNum = ln;
1470
- const totalFinal = repFinal.lines.length();
1471
-
1472
- const blankLinesToClean = [];
1473
- for (let li = finalLineNum + 1; li < totalFinal && li < finalLineNum + 5; li++) {
1474
- const lineEntry = repFinal.lines.atIndex(li);
1475
- const lineText = lineEntry?.text || '';
1476
-
1477
- // Check if this is a table row (should stop scanning)
1478
- let isTableRow = false;
1479
- try {
1480
- const attrStr = docManager.getAttributeOnLine(li, ATTR_TABLE_JSON);
1481
- if (attrStr) isTableRow = true;
1482
- } catch (_) {}
1483
-
1484
- if (isTableRow) break; // Stop at next table row
1485
-
1486
- // Check if blank line (empty or just whitespace)
1487
- if (!lineText.trim() || lineText === '\n') {
1488
- blankLinesToClean.push(li);
1489
- } else {
1490
- break; // Stop at first non-blank, non-table line
1491
- }
1492
- }
1493
-
1494
- // Remove blank lines bottom-up
1495
- // Check for DOM desync before destructive operations
1496
- if (blankLinesToClean.length > 0 && isDestructiveOperationSafe('postWriteCanonicalize blank-after removal')) {
1497
- blankLinesToClean.sort((a, b) => b - a).forEach((blankLineNum) => {
1498
- try {
1499
- const checkRep = ace.ace_getRep();
1500
- if (!isLineSafeToModify(checkRep, blankLineNum, '[ep_data_tables:postWriteCanonicalize] blank-after')) {
1501
- return;
1502
- }
1503
- console.debug('[ep_data_tables:postWriteCanonicalize] removing blank line after repair', {
1504
- blankLineNum, afterRow: capturedRow,
1505
- });
1506
- ace.ace_performDocumentReplaceRange([blankLineNum, 0], [blankLineNum + 1, 0], '');
1507
- } catch (blankErr) {
1508
- handleDomDesyncError(blankErr, 'postWriteCanonicalize blank-after');
1509
- }
1510
- });
1511
- }
1512
- }
1513
- } catch (finalCleanupErr) {
1514
- console.debug('[ep_data_tables:postWriteCanonicalize] final blank cleanup error (non-fatal)', finalCleanupErr?.message);
1515
- }
1516
-
1517
- try { ace.ace_fastIncorp(5); } catch (_) {}
1518
- } catch (innerErr) {
1519
- console.error('[ep_data_tables:postWriteCanonicalize] inner callback error', {
1520
- error: innerErr?.message || innerErr,
1521
- });
1522
- }
1523
- }, 'ep_data_tables:postwrite-canonicalize', true);
1524
- } catch (aceCallErr) {
1525
- // This catches errors from ace_callWithAce itself (Etherpad internal state corruption)
1526
- console.warn('[ep_data_tables:postWriteCanonicalize] ace_callWithAce failed - editor state may need refresh', {
1527
- error: aceCallErr?.message || aceCallErr,
1528
- });
1529
- }
1530
- } finally {
1531
- __epDT_postWriteScheduled.delete(nodeId);
1532
- if (__epDT_compositionOriginalLine.tblId === capturedTblId) {
1533
- __epDT_compositionOriginalLine = { tblId: null, lineNum: null, timestamp: 0 };
1534
- }
1535
- }
1536
- }, 0);
1537
- }
1538
- // Stop here; let the next render reflect the canonicalized text.
1539
- // BUT: if column operation is in progress, continue to render the table with current segments
1540
- if (!skipMismatchReturn) {
1541
- return cb();
1542
- }
1543
- // Otherwise, fall through to table building below
1544
- } else {
744
+ // GRACEFUL DEGRADATION: Log the mismatch but render the table with actual segments
745
+ // This prevents the cascade of: repair → re-render → mismatch → repair → crash
746
+ // The table will display with its actual content (may differ from metadata column count)
747
+ // Content and styling are preserved - user can edit normally
748
+ // If user edits this row, handleDesktopCommitInput will normalize it at that time
749
+ console.warn('[ep_data_tables][diag] Segment/column mismatch (rendering as-is)', {
750
+ nodeId, lineNum,
751
+ actualSegs: htmlSegments.length,
752
+ expectedCols: rowMetadata.cols,
753
+ tblId: rowMetadata.tblId,
754
+ row: rowMetadata.row
755
+ });
756
+
757
+ // Always continue to render - don't return early, don't schedule repair
758
+ skipMismatchReturn = true;
1545
759
  }
1546
760
 
1547
761
  try {
@@ -3387,12 +2601,18 @@ exports.aceInitialized = (h, ctx) => {
3387
2601
  let originalTableLine = null; // Track where the table actually is
3388
2602
 
3389
2603
  // Recover cursor if browser moved it to wrong line between compositions
2604
+ // CONSERVATIVE: More validation, shorter timeout, always clear after check
3390
2605
  let usedCursorRecovery = false;
3391
2606
  if (desktopComposition && desktopComposition._expectedCursor) {
3392
2607
  const stored = desktopComposition._expectedCursor;
3393
2608
  const timeSinceStored = Date.now() - stored.timestamp;
3394
2609
 
3395
- if (timeSinceStored < 1000) {
2610
+ // Reduced timeout from 1000ms to 500ms for rapid typing safety
2611
+ const isFresh = timeSinceStored < 500;
2612
+ const sameTable = stored.tblId && tableMetadata && stored.tblId === tableMetadata.tblId;
2613
+ const reasonableLineDelta = typeof stored.lineNum === 'number' && Math.abs(lineNum - stored.lineNum) <= 1;
2614
+
2615
+ if (isFresh && sameTable && reasonableLineDelta) {
3396
2616
  const currentLine = lineNum;
3397
2617
  const expectedLine = stored.lineNum;
3398
2618
  const expectedCell = stored.cellIndex;
@@ -3401,26 +2621,35 @@ exports.aceInitialized = (h, ctx) => {
3401
2621
  const seemsWrong = lineChanged && (currentLine === expectedLine + 1);
3402
2622
 
3403
2623
  if (seemsWrong) {
3404
- const storedMeta = getTableMetadataForLine(expectedLine);
3405
- if (storedMeta && storedMeta.tblId === stored.tblId) {
3406
- lineNum = expectedLine;
3407
- cellIndex = expectedCell;
3408
- tableMetadata = storedMeta;
3409
- originalTableLine = expectedLine;
3410
- usedCursorRecovery = true;
2624
+ // Additional validation: verify stored position is still valid
2625
+ try {
2626
+ const storedEntry = rep0.lines.atIndex(expectedLine);
2627
+ const storedText = storedEntry?.text || '';
2628
+ const storedCells = storedText.split(DELIMITER);
3411
2629
 
3412
- try {
3413
- ed.ace_callWithAce((aceInstance) => {
3414
- aceInstance.ace_performSelectionChange([expectedLine, stored.col], [expectedLine, stored.col], false);
3415
- }, 'compositionstart-cursor-recovery', true);
3416
- } catch (_) {}
3417
- }
2630
+ // Only recover if cell structure matches expectations
2631
+ if (expectedCell >= 0 && expectedCell < storedCells.length) {
2632
+ const storedMeta = getTableMetadataForLine(expectedLine);
2633
+ if (storedMeta && storedMeta.tblId === stored.tblId) {
2634
+ lineNum = expectedLine;
2635
+ cellIndex = expectedCell;
2636
+ tableMetadata = storedMeta;
2637
+ originalTableLine = expectedLine;
2638
+ usedCursorRecovery = true;
2639
+
2640
+ try {
2641
+ ed.ace_callWithAce((aceInstance) => {
2642
+ aceInstance.ace_performSelectionChange([expectedLine, stored.col], [expectedLine, stored.col], false);
2643
+ }, 'compositionstart-cursor-recovery', true);
2644
+ } catch (_) {}
2645
+ }
2646
+ }
2647
+ } catch (_) { /* validation failed, don't recover */ }
3418
2648
  }
3419
2649
  }
3420
2650
 
3421
- if (timeSinceStored > 1000 || usedCursorRecovery) {
3422
- delete desktopComposition._expectedCursor;
3423
- }
2651
+ // ALWAYS clear after checking to prevent stale reuse
2652
+ delete desktopComposition._expectedCursor;
3424
2653
  }
3425
2654
 
3426
2655
  try {
@@ -4867,12 +4096,29 @@ exports.aceInitialized = (h, ctx) => {
4867
4096
  const dataPreview = typeof nativeEvt?.data === 'string' ? nativeEvt.data : '';
4868
4097
  logCompositionEvent('compositionend-desktop-fired', evt, { data: dataPreview });
4869
4098
 
4870
- // Capture desktopComposition state before async ops
4871
- // which would overwrite desktopComposition and cause us to use the wrong state.
4872
- const capturedComposition = desktopComposition ? { ...desktopComposition } : null;
4873
-
4874
- // Prevent the immediate post-composition input commit from running; we pipeline instead
4875
- suppressNextInputCommit = true;
4099
+ // CRITICAL: Capture composition state with DEEP COPY before async deferral.
4100
+ // This prevents race conditions where a new compositionstart overwrites state
4101
+ // before this callback executes. Deep copy all arrays to prevent shared references.
4102
+ const capturedComposition = desktopComposition
4103
+ ? {
4104
+ active: desktopComposition.active,
4105
+ start: desktopComposition.start ? desktopComposition.start.slice() : null,
4106
+ end: desktopComposition.end ? desktopComposition.end.slice() : null,
4107
+ lineNum: desktopComposition.lineNum,
4108
+ cellIndex: desktopComposition.cellIndex,
4109
+ tblId: desktopComposition.tblId,
4110
+ snapshot: desktopComposition.snapshot ? desktopComposition.snapshot.slice() : null,
4111
+ snapshotMeta: desktopComposition.snapshotMeta ? { ...desktopComposition.snapshotMeta } : null,
4112
+ originalTableLine: desktopComposition.originalTableLine,
4113
+ usedCursorRecovery: desktopComposition.usedCursorRecovery,
4114
+ }
4115
+ : null;
4116
+
4117
+ // Capture the commit string synchronously too (before any async deferral)
4118
+ const commitStrRawSync = typeof nativeEvt?.data === 'string' ? nativeEvt.data : '';
4119
+
4120
+ // Prevent the immediate post-composition input commit from running; we pipeline instead
4121
+ suppressNextInputCommit = true;
4876
4122
  requestAnimationFrame(() => {
4877
4123
  try {
4878
4124
  ed.ace_callWithAce((aceInstance) => {
@@ -4892,12 +4138,12 @@ exports.aceInitialized = (h, ctx) => {
4892
4138
  return;
4893
4139
  }
4894
4140
 
4895
- // Pipeline: Apply committed IME string synchronously to the target cell
4896
- const commitStrRaw = typeof nativeEvt?.data === 'string' ? nativeEvt.data : '';
4897
- const commitStr = sanitizeCellContent(commitStrRaw || '');
4141
+ // Pipeline: Apply committed IME string to the target cell
4142
+ // Use the synchronously captured commit string (commitStrRawSync) to avoid race conditions
4143
+ const commitStr = sanitizeCellContent(commitStrRawSync || '');
4898
4144
  // Only suppress the next beforeinput commit if the IME provided a non-empty commit string.
4899
4145
  // Normalize using the same soft-whitespace rules the editor uses.
4900
- const willCommit = typeof commitStrRaw === 'string' && normalizeSoftWhitespace(commitStrRaw).trim().length > 0;
4146
+ const willCommit = typeof commitStrRawSync === 'string' && normalizeSoftWhitespace(commitStrRawSync).trim().length > 0;
4901
4147
  if (willCommit) suppressNextBeforeinputCommitOnce = true;
4902
4148
  const repNow = aceInstance.ace_getRep();
4903
4149
  const caret = repNow && repNow.selStart;
@@ -5025,15 +4271,44 @@ exports.aceInitialized = (h, ctx) => {
5025
4271
  }
5026
4272
  const cellsNow = (entry.text || '').split(DELIMITER);
5027
4273
  while (cellsNow.length < metadata.cols) cellsNow.push(' ');
5028
- // Prefer the cell index captured at compositionstart; otherwise compute using RAW mapping.
5029
- let idx = (capturedComposition && capturedComposition.cellIndex >= 0)
5030
- ? capturedComposition.cellIndex
5031
- : (() => {
5032
- const selCol = (capturedComposition && capturedComposition.start) ? capturedComposition.start[1] : (caret ? caret[1] : 0);
5033
- const rawMap = computeTargetCellIndexFromRaw(entry, selCol);
5034
- return rawMap.index;
5035
- })();
5036
- if (idx < 0) idx = Math.min(metadata.cols - 1, 0);
4274
+
4275
+ // DEFENSIVE CELL INDEX RESOLUTION:
4276
+ // 1. Prefer the cell index captured at compositionstart
4277
+ // 2. Fall back to live DOM selection (authoritative for current cursor)
4278
+ // 3. Fall back to raw line text mapping using current caret
4279
+ // 4. Final fallback: first cell (ensures we never lose text)
4280
+ let idx = -1;
4281
+
4282
+ // Strategy 1: Use captured cell index from compositionstart
4283
+ if (capturedComposition && capturedComposition.cellIndex >= 0) {
4284
+ idx = capturedComposition.cellIndex;
4285
+ }
4286
+
4287
+ // Strategy 2: Use live DOM selection as authoritative source
4288
+ if (idx < 0) {
4289
+ try {
4290
+ const liveDomTarget = getDomCellTargetFromSelection();
4291
+ if (liveDomTarget && typeof liveDomTarget.idx === 'number' && liveDomTarget.idx >= 0) {
4292
+ idx = liveDomTarget.idx;
4293
+ }
4294
+ } catch (_) { /* ignore DOM errors */ }
4295
+ }
4296
+
4297
+ // Strategy 3: Compute from raw line text using captured or current caret
4298
+ if (idx < 0) {
4299
+ try {
4300
+ const selCol = (capturedComposition && capturedComposition.start)
4301
+ ? capturedComposition.start[1]
4302
+ : (caret ? caret[1] : 0);
4303
+ const rawMap = computeTargetCellIndexFromRaw(entry, selCol);
4304
+ if (rawMap.index >= 0) idx = rawMap.index;
4305
+ } catch (_) { /* ignore */ }
4306
+ }
4307
+
4308
+ // Strategy 4: Final fallback - first cell (ensures no data loss)
4309
+ if (idx < 0) idx = 0;
4310
+ // Clamp to valid range
4311
+ if (idx >= metadata.cols) idx = metadata.cols - 1;
5037
4312
 
5038
4313
  // Compute relative selection in cell
5039
4314
  let baseOffset = 0;
@@ -5100,7 +4375,21 @@ exports.aceInitialized = (h, ctx) => {
5100
4375
  }
5101
4376
 
5102
4377
  // Post-composition orphan detection and repair using snapshot
4378
+ // Capture timestamp for staleness check
4379
+ const orphanRepairScheduledAt = Date.now();
5103
4380
  setTimeout(() => {
4381
+ // GUARD: Skip if a new composition started since we scheduled this
4382
+ // This prevents concurrent document modifications during rapid typing
4383
+ if (__epDT_compositionActive) {
4384
+ console.debug('[ep_data_tables:compositionend-orphan-repair] skipped - new composition active');
4385
+ return;
4386
+ }
4387
+ // Also skip if another composition just ended (within 30ms)
4388
+ if (Date.now() - __epDT_lastCompositionEndTime < 30 && __epDT_lastCompositionEndTime > orphanRepairScheduledAt) {
4389
+ console.debug('[ep_data_tables:compositionend-orphan-repair] skipped - very recent composition end');
4390
+ return;
4391
+ }
4392
+
5104
4393
  // Pre-check editor state
5105
4394
  // (keyToNodeMap.get(...) is undefined error)
5106
4395
  try {
@@ -5472,7 +4761,10 @@ exports.aceInitialized = (h, ctx) => {
5472
4761
  }
5473
4762
  }, 50); // Small delay to let Etherpad process the cell edit first
5474
4763
 
4764
+ // Reset composition state but PRESERVE _expectedCursor for next compositionstart
4765
+ const preservedCursor = desktopComposition._expectedCursor;
5475
4766
  desktopComposition = { active: false, start: null, end: null, lineNum: null, cellIndex: -1, snapshot: null, snapshotMeta: null };
4767
+ if (preservedCursor) desktopComposition._expectedCursor = preservedCursor;
5476
4768
  }, 'tableDesktopCompositionEnd');
5477
4769
  } catch (compositionErr) {
5478
4770
  console.error(`${compLogPrefix} ERROR during desktop composition repair:`, compositionErr);