Skip to content

Commit 3794645

Browse files
committed
feat: Enhance block update functionality to capture and restore cell selection in tables
1 parent 76eb776 commit 3794645

File tree

1 file changed

+108
-50
lines changed
  • packages/core/src/api/blockManipulation/commands/updateBlock

1 file changed

+108
-50
lines changed

packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts

Lines changed: 108 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
2828
import { getNodeById } from "../../../nodeUtil.js";
2929
import { getPmSchema } from "../../../pmUtil.js";
30+
import { TableMap } from "prosemirror-tables";
3031

3132
// for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface
3233
export const updateBlockCommand = <
@@ -64,30 +65,9 @@ export function updateBlockTr<
6465
) {
6566
const blockInfo = getBlockInfoFromResolvedPos(tr.doc.resolve(posBeforeBlock));
6667

67-
const originalSelection = tr.selection;
68-
// Capturing current selection info for restoring it after the block is updated.
69-
let anchorOffset: number | undefined = undefined;
70-
let headOffset: number | undefined = undefined;
71-
72-
if (
73-
originalSelection instanceof TextSelection &&
74-
blockInfo.isBlockContainer &&
75-
blockInfo.blockContent
76-
) {
77-
// Ensure both anchor and head are within the block's content before proceeding.
78-
const isAnchorInContent =
79-
originalSelection.anchor >= blockInfo.blockContent.beforePos + 1 &&
80-
originalSelection.anchor <= blockInfo.blockContent.afterPos - 1;
81-
const isHeadInContent =
82-
originalSelection.head >= blockInfo.blockContent.beforePos + 1 &&
83-
originalSelection.head <= blockInfo.blockContent.afterPos - 1;
84-
85-
if (isAnchorInContent && isHeadInContent) {
86-
anchorOffset =
87-
originalSelection.anchor - (blockInfo.blockContent.beforePos + 1);
88-
headOffset =
89-
originalSelection.head - (blockInfo.blockContent.beforePos + 1);
90-
}
68+
let cellAnchor: CellAnchor | null = null;
69+
if (blockInfo.blockNoteType === "table") {
70+
cellAnchor = captureCellAnchor(tr);
9171
}
9272

9373
const pmSchema = getPmSchema(tr);
@@ -170,32 +150,8 @@ export function updateBlockTr<
170150
...block.props,
171151
});
172152

173-
// Restore selection
174-
const newBlockInfo = getBlockInfoFromResolvedPos(
175-
tr.doc.resolve(tr.mapping.map(posBeforeBlock)),
176-
);
177-
178-
// If we captured relative offsets, try to restore the selection using them.
179-
if (
180-
anchorOffset !== undefined &&
181-
headOffset !== undefined &&
182-
newBlockInfo.isBlockContainer &&
183-
newBlockInfo.blockContent
184-
) {
185-
const contentNode = newBlockInfo.blockContent.node;
186-
const contentStartPos = newBlockInfo.blockContent.beforePos + 1;
187-
const contentEndPos = contentStartPos + contentNode.content.size;
188-
189-
const newAnchorPos = Math.min(
190-
contentStartPos + anchorOffset,
191-
contentEndPos,
192-
);
193-
const newHeadPos = Math.min(contentStartPos + headOffset, contentEndPos);
194-
195-
tr.setSelection(TextSelection.create(tr.doc, newAnchorPos, newHeadPos));
196-
} else {
197-
// Fallback to the default mapping if we couldn't use the offset method.
198-
tr.setSelection(originalSelection.map(tr.doc, tr.mapping));
153+
if (cellAnchor) {
154+
restoreCellAnchor(tr, blockInfo, cellAnchor);
199155
}
200156
}
201157

@@ -383,3 +339,105 @@ export function updateBlock<
383339
const pmSchema = getPmSchema(tr);
384340
return nodeToBlock(blockContainerNode, pmSchema);
385341
}
342+
343+
type CellAnchor = { row: number; col: number; offset: number };
344+
345+
/**
346+
* Captures the cell anchor from the current selection.
347+
* @param tr - The transaction to capture the cell anchor from.
348+
*
349+
* @returns The cell anchor, or null if no cell is selected.
350+
*/
351+
export function captureCellAnchor(tr: Transaction): CellAnchor | null {
352+
const sel = tr.selection;
353+
if (!(sel instanceof TextSelection)) return null;
354+
355+
const $head = tr.doc.resolve(sel.head);
356+
// Find enclosing cell and table
357+
let cellDepth = -1;
358+
let tableDepth = -1;
359+
for (let d = $head.depth; d >= 0; d--) {
360+
const name = $head.node(d).type.name;
361+
if (cellDepth < 0 && (name === "tableCell" || name === "tableHeader")) {
362+
cellDepth = d;
363+
}
364+
if (name === "table") {
365+
tableDepth = d;
366+
break;
367+
}
368+
}
369+
if (cellDepth < 0 || tableDepth < 0) return null;
370+
371+
// Absolute positions (before the cell)
372+
const cellPos = $head.before(cellDepth);
373+
const tablePos = $head.before(tableDepth);
374+
const table = tr.doc.nodeAt(tablePos);
375+
if (!table || table.type.name !== "table") return null;
376+
377+
// Visual grid position via TableMap (handles spans)
378+
const map = TableMap.get(table);
379+
const rel = cellPos - (tablePos + 1); // relative to inside table
380+
const idx = map.map.indexOf(rel);
381+
if (idx < 0) return null;
382+
383+
const row = Math.floor(idx / map.width);
384+
const col = idx % map.width;
385+
386+
// Caret offset relative to the start of paragraph text
387+
const paraPos = cellPos + 1; // pos BEFORE tableParagraph
388+
const textStart = paraPos + 1; // start of paragraph text
389+
const offset = Math.max(0, sel.head - textStart);
390+
391+
return { row, col, offset };
392+
}
393+
394+
function restoreCellAnchor(
395+
tr: Transaction,
396+
blockInfo: BlockInfo,
397+
a: CellAnchor,
398+
): boolean {
399+
if (blockInfo.blockNoteType !== "table") return false;
400+
401+
// 1) Resolve the table node in the current document
402+
let tablePos = -1;
403+
404+
if (blockInfo.isBlockContainer) {
405+
// Prefer the blockContent position when available (points directly at the PM table node)
406+
tablePos = tr.mapping.map(blockInfo.blockContent.beforePos);
407+
} else {
408+
// Fallback: scan within the mapped bnBlock range to find the inner table node
409+
const start = tr.mapping.map(blockInfo.bnBlock.beforePos);
410+
const end = start + (tr.doc.nodeAt(start)?.nodeSize || 0);
411+
tr.doc.nodesBetween(start, end, (node, pos) => {
412+
if (node.type.name === "table") {
413+
tablePos = pos;
414+
return false;
415+
}
416+
return true;
417+
});
418+
}
419+
420+
const table = tablePos >= 0 ? tr.doc.nodeAt(tablePos) : null;
421+
if (!table || table.type.name !== "table") return false;
422+
423+
// 2) Clamp row/col to the table’s current grid
424+
const map = TableMap.get(table);
425+
const row = Math.max(0, Math.min(a.row, map.height - 1));
426+
const col = Math.max(0, Math.min(a.col, map.width - 1));
427+
428+
// 3) Compute the absolute position of the target cell (pos BEFORE the cell)
429+
const cellIndex = row * map.width + col;
430+
const relCellPos = map.map[cellIndex]; // relative to (tablePos + 1)
431+
if (relCellPos == null) return false;
432+
const cellPos = tablePos + 1 + relCellPos;
433+
434+
// 4) Place the caret inside the cell, clamping the text offset
435+
const textPos = cellPos + 1;
436+
const textNode = tr.doc.nodeAt(textPos);
437+
const textStart = textPos + 1;
438+
const max = textNode ? textNode.content.size : 0;
439+
const head = textStart + Math.max(0, Math.min(a.offset, max));
440+
441+
tr.setSelection(TextSelection.create(tr.doc, head));
442+
return true;
443+
}

0 commit comments

Comments
 (0)