ep_data_tables 0.0.3 → 0.0.5

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.3",
3
+ "version": "0.0.5",
4
4
  "description": "BETA - etherpad tables plugin, compatible with other character/line based styling and other features",
5
5
  "author": {
6
6
  "name": "DCastelone",
@@ -60,6 +60,20 @@ let resizeOriginalWidths = [];
60
60
  let resizeTableMetadata = null;
61
61
  let resizeLineNum = -1;
62
62
  let resizeOverlay = null; // Visual overlay element
63
+ // Android Chrome composition handling state
64
+ let suppressNextBeforeInputInsertTextOnce = false;
65
+ let isAndroidChromeComposition = false;
66
+ let handledCurrentComposition = false;
67
+ // Suppress all beforeinput insertText events during an Android Chrome IME composition
68
+ let suppressBeforeInputInsertTextDuringComposition = false;
69
+ // Helper to detect any Android browser (exclude iOS/Safari)
70
+ function isAndroidUA() {
71
+ const ua = (navigator.userAgent || '').toLowerCase();
72
+ const isAndroid = ua.includes('android');
73
+ const isIOS = ua.includes('iphone') || ua.includes('ipad') || ua.includes('ipod') || ua.includes('crios');
74
+ // Safari on Android is rare (WebKit ports), but our exclusion target is iOS Safari; we exclude all iOS above
75
+ return isAndroid && !isIOS;
76
+ }
63
77
 
64
78
  // ─────────────────── Reusable Helper Functions ───────────────────
65
79
 
@@ -936,7 +950,7 @@ exports.acePostWriteDomLineHTML = function (hook_name, args, cb) {
936
950
  const delimiterCount = (delimitedTextFromLine || '').split(DELIMITER).length - 1;
937
951
  // log(`${logPrefix} NodeID#${nodeId}: Delimiter '${DELIMITER}' count in innerHTML: ${delimiterCount}`);
938
952
  // log(`${logPrefix} NodeID#${nodeId}: Expected delimiters for ${rowMetadata.cols} columns: ${rowMetadata.cols - 1}`);
939
-
953
+
940
954
  // log all delimiter positions
941
955
  let pos = -1;
942
956
  const delimiterPositions = [];
@@ -2180,10 +2194,99 @@ exports.aceInitialized = (h, ctx) => {
2180
2194
  const lineNum = selStart[0];
2181
2195
  // log(`${deleteLogPrefix} Current line number: ${lineNum}. Column start: ${selStart[1]}, Column end: ${selEnd[1]}.`);
2182
2196
 
2183
- // Check if there's actually a selection to delete
2184
- if (selStart[0] === selEnd[0] && selStart[1] === selEnd[1]) {
2185
- // log(`${deleteLogPrefix} No selection to delete. Allowing default delete.`);
2186
- return; // Allow default - nothing to delete
2197
+ // Android Chrome IME: collapsed backspace/forward-delete often comes via beforeinput
2198
+ const isAndroidChrome = isAndroidUA();
2199
+ const inputType = (evt.originalEvent && evt.originalEvent.inputType) || '';
2200
+
2201
+ // Handle collapsed deletes on Android Chrome inside a table line to protect delimiters
2202
+ const isCollapsed = (selStart[0] === selEnd[0] && selStart[1] === selEnd[1]);
2203
+ if (isCollapsed && isAndroidChrome && (inputType === 'deleteContentBackward' || inputType === 'deleteContentForward')) {
2204
+ // Resolve metadata for this line
2205
+ let lineAttrString = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2206
+ let tableMetadata = null;
2207
+ if (lineAttrString) { try { tableMetadata = JSON.parse(lineAttrString); } catch (_) {} }
2208
+ if (!tableMetadata) tableMetadata = getTableLineMetadata(lineNum, ed, docManager);
2209
+ if (!tableMetadata || typeof tableMetadata.cols !== 'number') {
2210
+ return; // Not a table line; allow default
2211
+ }
2212
+
2213
+ // Compute current cell and boundaries
2214
+ const lineText = rep.lines.atIndex(lineNum)?.text || '';
2215
+ const cells = lineText.split(DELIMITER);
2216
+ let currentOffset = 0;
2217
+ let targetCellIndex = -1;
2218
+ let cellStartCol = 0;
2219
+ let cellEndCol = 0;
2220
+ for (let i = 0; i < cells.length; i++) {
2221
+ const cellLength = cells[i]?.length ?? 0;
2222
+ const cellEndColThisIteration = currentOffset + cellLength;
2223
+ if (selStart[1] >= currentOffset && selStart[1] <= cellEndColThisIteration) {
2224
+ targetCellIndex = i;
2225
+ cellStartCol = currentOffset;
2226
+ cellEndCol = cellEndColThisIteration;
2227
+ break;
2228
+ }
2229
+ currentOffset += cellLength + DELIMITER.length;
2230
+ }
2231
+
2232
+ if (targetCellIndex === -1) return; // Allow default if not within a cell
2233
+
2234
+ const isBackward = inputType === 'deleteContentBackward';
2235
+ const caretCol = selStart[1];
2236
+
2237
+ // Prevent deletion across delimiters or line boundaries
2238
+ if ((isBackward && caretCol === cellStartCol) || (!isBackward && caretCol === cellEndCol)) {
2239
+ evt.preventDefault();
2240
+ return;
2241
+ }
2242
+
2243
+ // Intercept and perform one-character deletion via Ace
2244
+ evt.preventDefault();
2245
+ try {
2246
+ ed.ace_callWithAce((aceInstance) => {
2247
+ const delStart = isBackward ? [lineNum, caretCol - 1] : [lineNum, caretCol];
2248
+ const delEnd = isBackward ? [lineNum, caretCol] : [lineNum, caretCol + 1];
2249
+ aceInstance.ace_performDocumentReplaceRange(delStart, delEnd, '');
2250
+
2251
+ // Re-apply table metadata attribute to ensure renderer stability
2252
+ const repAfter = aceInstance.ace_getRep();
2253
+ ed.ep_data_tables_applyMeta(
2254
+ lineNum,
2255
+ tableMetadata.tblId,
2256
+ tableMetadata.row,
2257
+ tableMetadata.cols,
2258
+ repAfter,
2259
+ ed,
2260
+ null,
2261
+ docManager
2262
+ );
2263
+
2264
+ // Set caret position after deletion
2265
+ const newCaretCol = isBackward ? caretCol - 1 : caretCol;
2266
+ const newCaretPos = [lineNum, newCaretCol];
2267
+ aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2268
+ aceInstance.ace_fastIncorp(10);
2269
+
2270
+ // Update last-clicked state if available
2271
+ if (ed.ep_data_tables_editor && ed.ep_data_tables_editor.ep_data_tables_last_clicked && ed.ep_data_tables_editor.ep_data_tables_last_clicked.tblId === tableMetadata.tblId) {
2272
+ const newRelativePos = newCaretCol - cellStartCol;
2273
+ ed.ep_data_tables_editor.ep_data_tables_last_clicked = {
2274
+ lineNum: lineNum,
2275
+ tblId: tableMetadata.tblId,
2276
+ cellIndex: targetCellIndex,
2277
+ relativePos: newRelativePos < 0 ? 0 : newRelativePos,
2278
+ };
2279
+ }
2280
+ }, 'tableCollapsedDeleteHandler', true);
2281
+ } catch (error) {
2282
+ console.error(`${deleteLogPrefix} ERROR handling collapsed delete:`, error);
2283
+ }
2284
+ return;
2285
+ }
2286
+
2287
+ // Non-Android or non-collapsed: require an actual selection to handle here; otherwise allow default
2288
+ if (isCollapsed) {
2289
+ return; // Allow default
2187
2290
  }
2188
2291
 
2189
2292
  // Check if selection spans multiple lines
@@ -2337,6 +2440,319 @@ exports.aceInitialized = (h, ctx) => {
2337
2440
  }
2338
2441
  });
2339
2442
 
2443
+ // Android Chrome IME insert handling (targeted fix)
2444
+ $inner.on('beforeinput', (evt) => {
2445
+ const insertLogPrefix = '[ep_data_tables:beforeinputInsertHandler]';
2446
+ const inputType = evt.originalEvent && evt.originalEvent.inputType || '';
2447
+
2448
+ // Only intercept insert types
2449
+ if (!inputType || !inputType.startsWith('insert')) return;
2450
+
2451
+ // Target only Android browsers (exclude iOS)
2452
+ if (!isAndroidUA()) return;
2453
+
2454
+ // Get current selection and ensure we are inside a table line
2455
+ const rep = ed.ace_getRep();
2456
+ if (!rep || !rep.selStart) return;
2457
+ const selStart = rep.selStart;
2458
+ const selEnd = rep.selEnd;
2459
+ const lineNum = selStart[0];
2460
+
2461
+ // Resolve table metadata for this line
2462
+ let lineAttrString = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2463
+ let tableMetadata = null;
2464
+ if (lineAttrString) {
2465
+ try { tableMetadata = JSON.parse(lineAttrString); } catch (_) {}
2466
+ }
2467
+ if (!tableMetadata) tableMetadata = getTableLineMetadata(lineNum, ed, docManager);
2468
+ if (!tableMetadata || typeof tableMetadata.cols !== 'number' || typeof tableMetadata.tblId === 'undefined' || typeof tableMetadata.row === 'undefined') {
2469
+ return; // not a table line
2470
+ }
2471
+
2472
+ // Compute cell boundaries and target cell index
2473
+ const lineText = rep.lines.atIndex(lineNum)?.text || '';
2474
+ const cells = lineText.split(DELIMITER);
2475
+ let currentOffset = 0;
2476
+ let targetCellIndex = -1;
2477
+ let cellStartCol = 0;
2478
+ let cellEndCol = 0;
2479
+ for (let i = 0; i < cells.length; i++) {
2480
+ const cellLength = cells[i]?.length ?? 0;
2481
+ const cellEndColThisIteration = currentOffset + cellLength;
2482
+ if (selStart[1] >= currentOffset && selStart[1] <= cellEndColThisIteration) {
2483
+ targetCellIndex = i;
2484
+ cellStartCol = currentOffset;
2485
+ cellEndCol = cellEndColThisIteration;
2486
+ break;
2487
+ }
2488
+ currentOffset += cellLength + DELIMITER.length;
2489
+ }
2490
+
2491
+ // Clamp selection end if it includes a trailing delimiter of the same cell
2492
+ if (targetCellIndex !== -1 && selEnd[1] === cellEndCol + DELIMITER.length) {
2493
+ selEnd[1] = cellEndCol;
2494
+ }
2495
+
2496
+ // If selection spills outside the cell boundaries, block to protect structure
2497
+ if (targetCellIndex === -1 || selEnd[1] > cellEndCol) {
2498
+ evt.preventDefault();
2499
+ return;
2500
+ }
2501
+
2502
+ // Handle line breaks by routing to Enter behavior
2503
+ if (inputType === 'insertParagraph' || inputType === 'insertLineBreak') {
2504
+ evt.preventDefault();
2505
+ try {
2506
+ navigateToCellBelow(lineNum, targetCellIndex, tableMetadata, ed, docManager);
2507
+ } catch (e) { console.error(`${insertLogPrefix} Error navigating on line break:`, e); }
2508
+ return;
2509
+ }
2510
+
2511
+ // If we are in Android Chrome composition flow, suppress all insertText until composition ends
2512
+ if (suppressBeforeInputInsertTextDuringComposition && inputType === 'insertText') {
2513
+ evt.preventDefault();
2514
+ return;
2515
+ }
2516
+
2517
+ // If we already handled one insertion via composition handler, skip once (legacy single-shot)
2518
+ if (suppressNextBeforeInputInsertTextOnce && inputType === 'insertText') {
2519
+ suppressNextBeforeInputInsertTextOnce = false;
2520
+ evt.preventDefault();
2521
+ return;
2522
+ }
2523
+
2524
+ // If composition session is active, let composition handler manage
2525
+ if (isAndroidChromeComposition) return;
2526
+
2527
+ // Only proceed for textual insertions we can retrieve
2528
+ const rawData = evt.originalEvent && typeof evt.originalEvent.data === 'string' ? evt.originalEvent.data : '';
2529
+ // If no data for this insert type, allow default (paste/drop paths are handled elsewhere)
2530
+ if (!rawData) return;
2531
+
2532
+ // Sanitize inserted text: remove our delimiter and zero-width characters
2533
+ let insertedText = rawData
2534
+ .replace(new RegExp(DELIMITER, 'g'), ' ')
2535
+ .replace(/[\u200B\u200C\u200D\uFEFF]/g, '')
2536
+ .replace(/\s+/g, ' ');
2537
+
2538
+ if (insertedText.length === 0) {
2539
+ evt.preventDefault();
2540
+ return;
2541
+ }
2542
+
2543
+ // Intercept the browser default and perform the edit via Ace APIs
2544
+ evt.preventDefault();
2545
+ evt.stopPropagation();
2546
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
2547
+
2548
+ try {
2549
+ // Defer to next tick to avoid racing with browser internal composition state
2550
+ setTimeout(() => {
2551
+ ed.ace_callWithAce((aceInstance) => {
2552
+ aceInstance.ace_fastIncorp(10);
2553
+ const freshRep = aceInstance.ace_getRep();
2554
+ const freshSelStart = freshRep.selStart;
2555
+ const freshSelEnd = freshRep.selEnd;
2556
+
2557
+ // Replace selection with sanitized text
2558
+ aceInstance.ace_performDocumentReplaceRange(freshSelStart, freshSelEnd, insertedText);
2559
+
2560
+ // Re-apply cell attribute to the newly inserted text (clamped to line length)
2561
+ const repAfterReplace = aceInstance.ace_getRep();
2562
+ const freshLineIndex = freshSelStart[0];
2563
+ const freshLineEntry = repAfterReplace.lines.atIndex(freshLineIndex);
2564
+ const maxLen = Math.max(0, (freshLineEntry && freshLineEntry.text) ? freshLineEntry.text.length : 0);
2565
+ const startCol = Math.min(Math.max(freshSelStart[1], 0), maxLen);
2566
+ const endColRaw = startCol + insertedText.length;
2567
+ const endCol = Math.min(endColRaw, maxLen);
2568
+ if (endCol > startCol) {
2569
+ aceInstance.ace_performDocumentApplyAttributesToRange(
2570
+ [freshLineIndex, startCol], [freshLineIndex, endCol], [[ATTR_CELL, String(targetCellIndex)]]
2571
+ );
2572
+ }
2573
+
2574
+ // Re-apply table metadata line attribute
2575
+ ed.ep_data_tables_applyMeta(
2576
+ freshLineIndex,
2577
+ tableMetadata.tblId,
2578
+ tableMetadata.row,
2579
+ tableMetadata.cols,
2580
+ repAfterReplace,
2581
+ ed,
2582
+ null,
2583
+ docManager
2584
+ );
2585
+
2586
+ // Move caret to end of inserted text and update last-click state
2587
+ const newCaretCol = endCol;
2588
+ const newCaretPos = [freshLineIndex, newCaretCol];
2589
+ aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2590
+ aceInstance.ace_fastIncorp(10);
2591
+
2592
+ if (editor && editor.ep_data_tables_last_clicked && editor.ep_data_tables_last_clicked.tblId === tableMetadata.tblId) {
2593
+ // Recompute cellStartCol for the fresh line to avoid mismatches
2594
+ const freshLineText = (freshLineEntry && freshLineEntry.text) || '';
2595
+ const freshCells = freshLineText.split(DELIMITER);
2596
+ let freshOffset = 0;
2597
+ for (let i = 0; i < targetCellIndex; i++) {
2598
+ freshOffset += (freshCells[i]?.length ?? 0) + DELIMITER.length;
2599
+ }
2600
+ const newRelativePos = newCaretCol - freshOffset;
2601
+ editor.ep_data_tables_last_clicked = {
2602
+ lineNum: freshLineIndex,
2603
+ tblId: tableMetadata.tblId,
2604
+ cellIndex: targetCellIndex,
2605
+ relativePos: newRelativePos < 0 ? 0 : newRelativePos,
2606
+ };
2607
+ }
2608
+ }, 'tableInsertTextOperations', true);
2609
+ }, 0);
2610
+ } catch (error) {
2611
+ console.error(`${insertLogPrefix} ERROR during insert handling:`, error);
2612
+ }
2613
+ });
2614
+
2615
+ // Composition start marker (Android Chrome/table only)
2616
+ $inner.on('compositionstart', (evt) => {
2617
+ if (!isAndroidUA()) return;
2618
+ const rep = ed.ace_getRep();
2619
+ if (!rep || !rep.selStart) return;
2620
+ const lineNum = rep.selStart[0];
2621
+ let meta = null; let s = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2622
+ if (s) { try { meta = JSON.parse(s); } catch (_) {} }
2623
+ if (!meta) meta = getTableLineMetadata(lineNum, ed, docManager);
2624
+ if (!meta || typeof meta.cols !== 'number') return;
2625
+ isAndroidChromeComposition = true;
2626
+ handledCurrentComposition = false;
2627
+ suppressBeforeInputInsertTextDuringComposition = false;
2628
+ });
2629
+
2630
+ // Android Chrome composition handling for whitespace (space) to prevent DOM mutation breaking delimiters
2631
+ $inner.on('compositionupdate', (evt) => {
2632
+ const compLogPrefix = '[ep_data_tables:compositionHandler]';
2633
+
2634
+ if (!isAndroidUA()) return;
2635
+
2636
+ const rep = ed.ace_getRep();
2637
+ if (!rep || !rep.selStart) return;
2638
+ const selStart = rep.selStart;
2639
+ const selEnd = rep.selEnd;
2640
+ const lineNum = selStart[0];
2641
+
2642
+ // Ensure we are inside a table line
2643
+ let lineAttrString = docManager.getAttributeOnLine(lineNum, ATTR_TABLE_JSON);
2644
+ let tableMetadata = null;
2645
+ if (lineAttrString) { try { tableMetadata = JSON.parse(lineAttrString); } catch (_) {} }
2646
+ if (!tableMetadata) tableMetadata = getTableLineMetadata(lineNum, ed, docManager);
2647
+ if (!tableMetadata || typeof tableMetadata.cols !== 'number') return;
2648
+
2649
+ // Only act on whitespace-only composition updates (space/non-breaking space)
2650
+ const d = evt.originalEvent && typeof evt.originalEvent.data === 'string' ? evt.originalEvent.data : '';
2651
+ if (evt.type === 'compositionupdate') {
2652
+ const isWhitespaceOnly = d && d.replace(/\u00A0/g, ' ').trim() === '';
2653
+ if (!isWhitespaceOnly) return;
2654
+
2655
+ // Compute target cell and clamp selection to cell boundaries
2656
+ const lineText = rep.lines.atIndex(lineNum)?.text || '';
2657
+ const cells = lineText.split(DELIMITER);
2658
+ let currentOffset = 0;
2659
+ let targetCellIndex = -1;
2660
+ let cellStartCol = 0;
2661
+ let cellEndCol = 0;
2662
+ for (let i = 0; i < cells.length; i++) {
2663
+ const cellLength = cells[i]?.length ?? 0;
2664
+ const cellEndColThisIteration = currentOffset + cellLength;
2665
+ if (selStart[1] >= currentOffset && selStart[1] <= cellEndColThisIteration) {
2666
+ targetCellIndex = i;
2667
+ cellStartCol = currentOffset;
2668
+ cellEndCol = cellEndColThisIteration;
2669
+ break;
2670
+ }
2671
+ currentOffset += cellLength + DELIMITER.length;
2672
+ }
2673
+ if (targetCellIndex === -1 || selEnd[1] > cellEndCol) return;
2674
+
2675
+ // Prevent composition DOM mutation and insert sanitized space via Ace
2676
+ evt.preventDefault();
2677
+ evt.stopPropagation();
2678
+ if (typeof evt.stopImmediatePropagation === 'function') evt.stopImmediatePropagation();
2679
+
2680
+ let insertedText = d.replace(/\u00A0/g, ' ');
2681
+ if (insertedText.length === 0) insertedText = ' ';
2682
+
2683
+ try {
2684
+ setTimeout(() => {
2685
+ ed.ace_callWithAce((aceInstance) => {
2686
+ aceInstance.ace_fastIncorp(10);
2687
+ const freshRep = aceInstance.ace_getRep();
2688
+ const freshSelStart = freshRep.selStart;
2689
+ const freshSelEnd = freshRep.selEnd;
2690
+ aceInstance.ace_performDocumentReplaceRange(freshSelStart, freshSelEnd, insertedText);
2691
+
2692
+ // Clamp attribute application to current line bounds and use fresh line index
2693
+ const repAfterReplace = aceInstance.ace_getRep();
2694
+ const freshLineIndex = freshSelStart[0];
2695
+ const freshLineEntry = repAfterReplace.lines.atIndex(freshLineIndex);
2696
+ const maxLen = Math.max(0, (freshLineEntry && freshLineEntry.text) ? freshLineEntry.text.length : 0);
2697
+ const startCol = Math.min(Math.max(freshSelStart[1], 0), maxLen);
2698
+ const endColRaw = startCol + insertedText.length;
2699
+ const endCol = Math.min(endColRaw, maxLen);
2700
+ if (endCol > startCol) {
2701
+ aceInstance.ace_performDocumentApplyAttributesToRange(
2702
+ [freshLineIndex, startCol], [freshLineIndex, endCol], [[ATTR_CELL, String(targetCellIndex)]]
2703
+ );
2704
+ }
2705
+
2706
+ ed.ep_data_tables_applyMeta(
2707
+ freshLineIndex,
2708
+ tableMetadata.tblId,
2709
+ tableMetadata.row,
2710
+ tableMetadata.cols,
2711
+ repAfterReplace,
2712
+ ed,
2713
+ null,
2714
+ docManager
2715
+ );
2716
+
2717
+ const newCaretCol = endCol;
2718
+ const newCaretPos = [freshLineIndex, newCaretCol];
2719
+ aceInstance.ace_performSelectionChange(newCaretPos, newCaretPos, false);
2720
+ aceInstance.ace_fastIncorp(10);
2721
+ if (editor && editor.ep_data_tables_last_clicked && editor.ep_data_tables_last_clicked.tblId === tableMetadata.tblId) {
2722
+ // Recompute cellStartCol for fresh line
2723
+ const freshLineText = (freshLineEntry && freshLineEntry.text) || '';
2724
+ const freshCells = freshLineText.split(DELIMITER);
2725
+ let freshOffset = 0;
2726
+ for (let i = 0; i < targetCellIndex; i++) {
2727
+ freshOffset += (freshCells[i]?.length ?? 0) + DELIMITER.length;
2728
+ }
2729
+ const newRelativePos = newCaretCol - freshOffset;
2730
+ editor.ep_data_tables_last_clicked = {
2731
+ lineNum: freshLineIndex,
2732
+ tblId: tableMetadata.tblId,
2733
+ cellIndex: targetCellIndex,
2734
+ relativePos: newRelativePos < 0 ? 0 : newRelativePos,
2735
+ };
2736
+ }
2737
+ }, 'tableCompositionSpaceInsert', true);
2738
+ }, 0);
2739
+ // Suppress all subsequent beforeinput insertText events in this composition session
2740
+ suppressBeforeInputInsertTextDuringComposition = true;
2741
+ } catch (error) {
2742
+ console.error(`${compLogPrefix} ERROR inserting space during composition:`, error);
2743
+ }
2744
+ }
2745
+ });
2746
+
2747
+ // Composition end cleanup
2748
+ $inner.on('compositionend', () => {
2749
+ if (isAndroidChromeComposition) {
2750
+ isAndroidChromeComposition = false;
2751
+ handledCurrentComposition = false;
2752
+ suppressBeforeInputInsertTextDuringComposition = false;
2753
+ }
2754
+ });
2755
+
2340
2756
  // *** DRAG AND DROP EVENT LISTENERS ***
2341
2757
  // log(`${callWithAceLogPrefix} Attaching drag and drop event listeners to $inner (inner iframe body).`);
2342
2758