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 +1 -1
- package/static/js/client_hooks.js +129 -837
package/package.json
CHANGED
|
@@ -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
|
-
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
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
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
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
|
-
|
|
3422
|
-
|
|
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
|
|
4871
|
-
//
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
|
|
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
|
|
4896
|
-
|
|
4897
|
-
const commitStr = sanitizeCellContent(
|
|
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
|
|
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
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
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);
|