@jackuait/blok 0.10.2 → 0.10.3

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.
@@ -86,6 +86,8 @@ interface CellSelectionOptions {
86
86
  isMergedCell?: (row: number, col: number) => boolean;
87
87
  /** Called when user requests to split a merged cell. */
88
88
  onSplitCell?: (row: number, col: number) => void;
89
+ /** Returns the colspan and rowspan of the cell at (row, col). Used to expand the selection rect to full merged-cell spans. */
90
+ getCellSpan?: (row: number, col: number) => { colspan: number; rowspan: number };
89
91
  i18n: I18n;
90
92
  }
91
93
 
@@ -118,7 +120,9 @@ export class TableCellSelection {
118
120
  private onMergeCells: ((range: SelectionRange) => void) | undefined;
119
121
  private isMergedCell: ((row: number, col: number) => boolean) | undefined;
120
122
  private onSplitCell: ((row: number, col: number) => void) | undefined;
123
+ private getCellSpan: ((row: number, col: number) => { colspan: number; rowspan: number }) | undefined;
121
124
  private lastPaintedRange: SelectionRange | null = null;
125
+ private preExpansionWasSingleCell = false;
122
126
 
123
127
  private boundPointerDown: (e: PointerEvent) => void;
124
128
  private boundPointerMove: (e: PointerEvent) => void;
@@ -148,6 +152,7 @@ export class TableCellSelection {
148
152
  this.onMergeCells = options.onMergeCells;
149
153
  this.isMergedCell = options.isMergedCell;
150
154
  this.onSplitCell = options.onSplitCell;
155
+ this.getCellSpan = options.getCellSpan;
151
156
  this.i18n = options.i18n;
152
157
  this.grid.style.position = 'relative';
153
158
 
@@ -466,6 +471,14 @@ export class TableCellSelection {
466
471
  return;
467
472
  }
468
473
 
474
+ // For single-cell selections, if the user has a non-collapsed native text
475
+ // selection within the cell's contenteditable (i.e., they selected specific
476
+ // text characters), defer to the browser's native copy so their text
477
+ // selection is copied rather than the whole cell block structure.
478
+ if (this.selectedCells.length <= 1 && this.hasNativeTextSelection()) {
479
+ return;
480
+ }
481
+
469
482
  e.preventDefault();
470
483
  this.onCopy?.([...this.selectedCells], e.clipboardData);
471
484
  }
@@ -475,18 +488,37 @@ export class TableCellSelection {
475
488
  return;
476
489
  }
477
490
 
491
+ // For single-cell selections, if the user has a non-collapsed native text
492
+ // selection within the cell's contenteditable, defer to the browser's native
493
+ // cut so their text selection is cut rather than clearing the entire cell.
494
+ if (this.selectedCells.length <= 1 && this.hasNativeTextSelection()) {
495
+ return;
496
+ }
497
+
478
498
  e.preventDefault();
479
499
  this.onCut?.([...this.selectedCells], e.clipboardData);
480
500
  this.onClearContent?.([...this.selectedCells]);
481
501
  this.clearSelection();
482
502
  }
483
503
 
504
+ /**
505
+ * Returns true if the browser has a non-collapsed text selection (i.e. the
506
+ * user has selected one or more characters inside a contenteditable), as
507
+ * opposed to a mere caret position or no selection at all.
508
+ */
509
+ private hasNativeTextSelection(): boolean {
510
+ const selection = window.getSelection();
511
+
512
+ return selection !== null && !selection.isCollapsed;
513
+ }
514
+
484
515
  private clearSelection(): void {
485
516
  const hadSelection = this.hasSelection;
486
517
 
487
518
  this.restoreModifiedCells();
488
519
  this.hasSelection = false;
489
520
  this.lastPaintedRange = null;
521
+ this.preExpansionWasSingleCell = false;
490
522
 
491
523
  if (hadSelection) {
492
524
  this.onSelectionActiveChange?.(false);
@@ -540,6 +572,50 @@ export class TableCellSelection {
540
572
  document.addEventListener('pointerdown', this.boundClearSelection);
541
573
  }
542
574
 
575
+ /**
576
+ * Expand a selection rect to fully include the spans of any merged cells
577
+ * whose origins fall within the rect. Iterates until the rect is stable,
578
+ * since pulling in a new merged cell may expose further cells that extend
579
+ * beyond the current boundary.
580
+ */
581
+ private expandRectToMergedSpans(rect: SelectionRange): SelectionRange {
582
+ if (!this.getCellSpan) {
583
+ return rect;
584
+ }
585
+
586
+ return this.expandRectStep(rect);
587
+ }
588
+
589
+ private expandRectStep(rect: SelectionRange): SelectionRange {
590
+ const getCellSpan = this.getCellSpan;
591
+
592
+ if (!getCellSpan) {
593
+ return rect;
594
+ }
595
+
596
+ const rows = Array.from({ length: rect.maxRow - rect.minRow + 1 }, (_, i) => rect.minRow + i);
597
+ const cols = Array.from({ length: rect.maxCol - rect.minCol + 1 }, (_, i) => rect.minCol + i);
598
+
599
+ const expanded = rows.reduce<SelectionRange>((acc, r) => {
600
+ return cols.reduce<SelectionRange>((inner, c) => {
601
+ const { colspan, rowspan } = getCellSpan(r, c);
602
+
603
+ return {
604
+ minRow: inner.minRow,
605
+ maxRow: Math.max(inner.maxRow, r + rowspan - 1),
606
+ minCol: inner.minCol,
607
+ maxCol: Math.max(inner.maxCol, c + colspan - 1),
608
+ };
609
+ }, acc);
610
+ }, rect);
611
+
612
+ const changed =
613
+ expanded.maxRow !== rect.maxRow ||
614
+ expanded.maxCol !== rect.maxCol;
615
+
616
+ return changed ? this.expandRectStep(expanded) : expanded;
617
+ }
618
+
543
619
  private paintSelection(): void {
544
620
  if (!this.anchorCell || !this.extentCell) {
545
621
  return;
@@ -557,12 +633,15 @@ export class TableCellSelection {
557
633
  const minCol = Math.min(this.anchorCell.col, this.extentCell.col);
558
634
  const maxCol = Math.max(this.anchorCell.col, this.extentCell.col);
559
635
 
560
- this.lastPaintedRange = { minRow, maxRow, minCol, maxCol };
636
+ this.preExpansionWasSingleCell = minRow === maxRow && minCol === maxCol;
637
+ this.lastPaintedRange = this.expandRectToMergedSpans({ minRow, maxRow, minCol, maxCol });
638
+
639
+ const { minRow: expandedMinRow, maxRow: expandedMaxRow, minCol: expandedMinCol, maxCol: expandedMaxCol } = this.lastPaintedRange;
561
640
 
562
641
  const rows = this.grid.querySelectorAll(`[${ROW_ATTR}]`);
563
642
 
564
643
  // Mark selected cells
565
- this.selectedCells = this.collectCellsInRange(rows, minRow, maxRow, minCol, maxCol);
644
+ this.selectedCells = this.collectCellsInRange(rows, expandedMinRow, expandedMaxRow, expandedMinCol, expandedMaxCol);
566
645
  this.selectedCells.forEach(cell => {
567
646
  cell.setAttribute(SELECTED_ATTR, '');
568
647
  });
@@ -570,8 +649,8 @@ export class TableCellSelection {
570
649
  // Calculate overlay position from bounding rects of corner cells.
571
650
  // Try coordinate-based lookup first (works with merged cells),
572
651
  // then fall back to index-based lookup for backwards compatibility.
573
- const firstCell = this.findCellByCoordOrIndex(rows, minRow, minCol);
574
- const lastCell = this.findCellByCoordOrIndex(rows, maxRow, maxCol);
652
+ const firstCell = this.findCellByCoordOrIndex(rows, expandedMinRow, expandedMinCol);
653
+ const lastCell = this.findCellByCoordOrIndex(rows, expandedMaxRow, expandedMaxCol);
575
654
 
576
655
  if (!firstCell || !lastCell) {
577
656
  return;
@@ -803,7 +882,7 @@ export class TableCellSelection {
803
882
  if (this.lastPaintedRange && this.onMergeCells) {
804
883
  const range = this.lastPaintedRange;
805
884
  const isMultiCell = range.minRow !== range.maxRow || range.minCol !== range.maxCol;
806
- const canMerge = isMultiCell && this.canMergeCells?.(range);
885
+ const canMerge = isMultiCell && !this.preExpansionWasSingleCell && this.canMergeCells?.(range);
807
886
 
808
887
  if (canMerge) {
809
888
  mergeItems.push({
@@ -821,8 +900,9 @@ export class TableCellSelection {
821
900
  if (this.lastPaintedRange && this.onSplitCell) {
822
901
  const range = this.lastPaintedRange;
823
902
  const isSingleCell = range.minRow === range.maxRow && range.minCol === range.maxCol;
903
+ const isSingleOriginExpanded = this.preExpansionWasSingleCell && this.isMergedCell?.(range.minRow, range.minCol);
824
904
 
825
- if (isSingleCell && this.isMergedCell?.(range.minRow, range.minCol)) {
905
+ if ((isSingleCell || isSingleOriginExpanded) && this.isMergedCell?.(range.minRow, range.minCol)) {
826
906
  mergeItems.push({
827
907
  icon: IconSplitCell,
828
908
  title: this.i18n.t('tools.table.splitCell'),
@@ -936,6 +1016,23 @@ export class TableCellSelection {
936
1016
  return null;
937
1017
  }
938
1018
 
1019
+ // Prefer logical coordinate attributes stamped by reindexCoordinates() —
1020
+ // these are correct even when rows have fewer physical <td> elements than
1021
+ // logical columns (e.g. after a colspan/rowspan merge).
1022
+ const cellRowAttr = cell.getAttribute(CELL_ROW_ATTR);
1023
+ const cellColAttr = cell.getAttribute(CELL_COL_ATTR);
1024
+
1025
+ if (cellRowAttr !== null && cellColAttr !== null) {
1026
+ const rowIndex = parseInt(cellRowAttr, 10);
1027
+ const colIndex = parseInt(cellColAttr, 10);
1028
+
1029
+ if (!isNaN(rowIndex) && !isNaN(colIndex)) {
1030
+ return { row: rowIndex, col: colIndex };
1031
+ }
1032
+ }
1033
+
1034
+ // Fallback: physical DOM index — only used for grids without coordinate
1035
+ // attributes (e.g. legacy non-table grid elements).
939
1036
  const rows = Array.from(this.grid.querySelectorAll(`[${ROW_ATTR}]`));
940
1037
  const rowIndex = rows.indexOf(row);
941
1038
 
@@ -1025,6 +1122,12 @@ export class TableCellSelection {
1025
1122
  /**
1026
1123
  * Find a cell by coordinate attributes first, falling back to index-based
1027
1124
  * lookup when coordinate attributes are not present.
1125
+ *
1126
+ * When both primary lookups fail (e.g. `col` points to a covered logical
1127
+ * column that has no physical <td> of its own), scan all cells in the row
1128
+ * and return the one whose colspan range covers `col`. This handles the
1129
+ * case where `expandRectToMergedSpans` has expanded the selection corner to
1130
+ * a column that is spanned by an origin cell at a lower column index.
1028
1131
  */
1029
1132
  private findCellByCoordOrIndex(
1030
1133
  rows: NodeListOf<Element>,
@@ -1039,7 +1142,23 @@ export class TableCellSelection {
1039
1142
  return coordCell;
1040
1143
  }
1041
1144
 
1042
- return rows[row]?.querySelectorAll(`[${CELL_ATTR}]`)[col] as HTMLElement | undefined;
1145
+ const indexCell = rows[row]?.querySelectorAll(`[${CELL_ATTR}]`)[col] as HTMLElement | undefined;
1146
+
1147
+ if (indexCell) {
1148
+ return indexCell;
1149
+ }
1150
+
1151
+ // Neither coord-based nor index-based lookup found a cell. Walk all
1152
+ // physical cells in the row to find one whose logical column range covers
1153
+ // the requested column (origin cellCol <= col <= cellCol + colSpan - 1).
1154
+ const rowCells = Array.from(rows[row]?.querySelectorAll<HTMLElement>(`[${CELL_ATTR}]`) ?? []);
1155
+
1156
+ return rowCells.find(cell => {
1157
+ const cellCol = Number(cell.getAttribute(CELL_COL_ATTR));
1158
+ const cellColSpan = (cell as HTMLTableCellElement).colSpan || 1;
1159
+
1160
+ return cellCol <= col && cellCol + cellColSpan - 1 >= col;
1161
+ });
1043
1162
  }
1044
1163
 
1045
1164
  /**
@@ -413,17 +413,71 @@ export class TableGrid {
413
413
  /**
414
414
  * Reindex coordinate attributes on all cells after structural changes.
415
415
  * Sets data-blok-table-cell-row and data-blok-table-cell-col to match
416
- * each cell's current physical position.
416
+ * each cell's model (logical) position, accounting for colspan and rowspan.
417
+ *
418
+ * Uses sparse table reconstruction: tracks columns blocked by rowspan cells
419
+ * from previous rows so that each DOM cell gets the correct model column index
420
+ * rather than its physical DOM index.
417
421
  */
418
422
  public reindexCoordinates(table: HTMLElement): void {
419
- const rows = table.querySelectorAll(`[${ROW_ATTR}]`);
423
+ const rows = Array.from(table.querySelectorAll(`[${ROW_ATTR}]`));
424
+
425
+ // Map from rowIndex -> Set of columnIndices occupied by rowspan cells from earlier rows
426
+ const occupiedCols: Map<number, Set<number>> = new Map();
420
427
 
421
428
  rows.forEach((row, r) => {
422
- const cells = row.querySelectorAll(`[${CELL_ATTR}]`);
429
+ const cells = Array.from(row.querySelectorAll(`[${CELL_ATTR}]`));
430
+ const blockedCols = occupiedCols.get(r) ?? new Set<number>();
431
+
432
+ cells.reduce((modelCol, cell) => {
433
+ const tdCell = cell as HTMLTableCellElement;
434
+
435
+ // Skip columns that are occupied by rowspan cells from previous rows
436
+ const skipBlocked = (c: number): number => (blockedCols.has(c) ? skipBlocked(c + 1) : c);
437
+ const col = skipBlocked(modelCol);
423
438
 
424
- cells.forEach((cell, c) => {
425
439
  cell.setAttribute(CELL_ROW_ATTR, String(r));
426
- cell.setAttribute(CELL_COL_ATTR, String(c));
440
+ cell.setAttribute(CELL_COL_ATTR, String(col));
441
+
442
+ const colSpan = tdCell.colSpan || 1;
443
+ const rowSpan = tdCell.rowSpan || 1;
444
+
445
+ // If this cell has rowspan > 1, mark those columns as blocked in subsequent rows
446
+ if (rowSpan > 1) {
447
+ this.blockRowspanCols(occupiedCols, r, col, rowSpan, colSpan);
448
+ }
449
+
450
+ // Advance by colspan
451
+ return col + colSpan;
452
+ }, 0);
453
+
454
+ occupiedCols.delete(r);
455
+ });
456
+ }
457
+
458
+ /**
459
+ * Register blocked columns in occupiedCols for a cell with rowspan > 1.
460
+ * All columns in [startCol, startCol + colSpan) are blocked for rows
461
+ * [startRow + 1, startRow + rowSpan).
462
+ */
463
+ private blockRowspanCols(
464
+ occupiedCols: Map<number, Set<number>>,
465
+ startRow: number,
466
+ startCol: number,
467
+ rowSpan: number,
468
+ colSpan: number
469
+ ): void {
470
+ Array.from({ length: rowSpan - 1 }, (_, i) => i + 1).forEach((dr) => {
471
+ const futureRow = startRow + dr;
472
+
473
+ if (!occupiedCols.has(futureRow)) {
474
+ occupiedCols.set(futureRow, new Set());
475
+ }
476
+
477
+ const blocked = occupiedCols.get(futureRow) as Set<number>;
478
+
479
+ Array.from({ length: colSpan }, (_, dc) => dc).forEach((dc) => {
480
+ blocked.add(startCol + dc);
427
481
  });
428
482
  });
429
483
  }
@@ -181,6 +181,10 @@ export class TableModel {
181
181
  return;
182
182
  }
183
183
 
184
+ if (this.isSpannedCell(row, col)) {
185
+ return;
186
+ }
187
+
184
188
  // Enforce invariant 5: no block in more than one cell
185
189
  const existing = this.blockCellMap.get(blockId);
186
190
 
@@ -221,6 +225,10 @@ export class TableModel {
221
225
  return;
222
226
  }
223
227
 
228
+ if (this.isSpannedCell(row, col)) {
229
+ return;
230
+ }
231
+
224
232
  // Remove old entries from map
225
233
  for (const oldId of this.contentGrid[row][col].blocks) {
226
234
  this.blockCellMap.delete(oldId);
@@ -1,7 +1,7 @@
1
1
  import type { I18n } from '../../../types/api';
2
2
  import { twMerge } from '../../components/utils/tw';
3
3
 
4
- import { BORDER_WIDTH, CELL_ATTR, ROW_ATTR } from './table-core';
4
+ import { BORDER_WIDTH, CELL_ATTR, CELL_COL_ATTR, CELL_ROW_ATTR, ROW_ATTR } from './table-core';
5
5
  import { collapseGrip, createGripDotsSvg, expandGrip, GRIP_HOVER_SIZE, setGripPillSize } from './table-grip-visuals';
6
6
  import { getCumulativeColEdges, TableRowColDrag } from './table-row-col-drag';
7
7
  import { createGripPopover } from './table-row-col-popover';
@@ -419,11 +419,39 @@ export class TableRowColControls {
419
419
  }
420
420
 
421
421
  const rowEl = rows[i] as HTMLElement;
422
- const centerY = rowEl.offsetTop + rowEl.offsetHeight / 2;
423
- const style = grip.style;
424
422
 
425
- style.left = `${-BORDER_WIDTH / 2}px`;
426
- style.top = `${centerY}px`;
423
+ // Find the cell with the maximum rowSpan in this row to correctly
424
+ // center the grip over merged cells that span multiple rows.
425
+ const cellsInRow = this.grid.querySelectorAll<HTMLElement>(`[${CELL_ROW_ATTR}="${i}"]`);
426
+ const originCell = Array.from(cellsInRow).reduce<HTMLTableCellElement | null>((best, cell) => {
427
+ const tdCell = cell as HTMLTableCellElement;
428
+ const bestSpan = best !== null ? best.rowSpan || 1 : 0;
429
+
430
+ return (tdCell.rowSpan || 1) > bestSpan ? tdCell : best;
431
+ }, null);
432
+ const maxRowSpan = originCell !== null ? (originCell.rowSpan || 1) : 1;
433
+
434
+ if (maxRowSpan > 1 && originCell !== null) {
435
+ // Use getBoundingClientRect() on the origin cell to get its actual rendered
436
+ // height — summing tr.offsetHeight is inaccurate when the merged cell's
437
+ // content forces the browser to redistribute height across rows (each
438
+ // individual tr.offsetHeight stays at its minimum rather than reflecting
439
+ // the full visual contribution of the merged content).
440
+ const container = this.overlay ?? this.grid;
441
+ const containerRect = container.getBoundingClientRect();
442
+ const cellRect = originCell.getBoundingClientRect();
443
+ const centerY = cellRect.top - containerRect.top + cellRect.height / 2;
444
+ const style = grip.style;
445
+
446
+ style.left = `${-BORDER_WIDTH / 2}px`;
447
+ style.top = `${centerY}px`;
448
+ } else {
449
+ const centerY = rowEl.offsetTop + rowEl.offsetHeight / 2;
450
+ const style = grip.style;
451
+
452
+ style.left = `${-BORDER_WIDTH / 2}px`;
453
+ style.top = `${centerY}px`;
454
+ }
427
455
  });
428
456
  }
429
457
 
@@ -482,27 +510,21 @@ export class TableRowColControls {
482
510
  }
483
511
 
484
512
  private getCellPosition(cell: HTMLElement): { row: number; col: number } | null {
485
- const row = cell.closest<HTMLElement>(`[${ROW_ATTR}]`);
486
-
487
- if (!row) {
488
- return null;
489
- }
490
-
491
- const rows = Array.from(this.grid.querySelectorAll(`[${ROW_ATTR}]`));
492
- const rowIndex = rows.indexOf(row);
513
+ const rowAttr = cell.getAttribute(CELL_ROW_ATTR);
514
+ const colAttr = cell.getAttribute(CELL_COL_ATTR);
493
515
 
494
- if (rowIndex < 0) {
516
+ if (rowAttr === null || colAttr === null) {
495
517
  return null;
496
518
  }
497
519
 
498
- const cells = Array.from(row.querySelectorAll(`[${CELL_ATTR}]`));
499
- const colIndex = cells.indexOf(cell);
520
+ const row = parseInt(rowAttr, 10);
521
+ const col = parseInt(colAttr, 10);
500
522
 
501
- if (colIndex < 0) {
523
+ if (isNaN(row) || isNaN(col)) {
502
524
  return null;
503
525
  }
504
526
 
505
- return { row: rowIndex, col: colIndex };
527
+ return { row, col };
506
528
  }
507
529
 
508
530
  /**