ep_data_tables 0.0.97 → 0.0.98

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.98",
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 {