Files

413 lines
12 KiB
TypeScript

// Table editing commands for the CodeMirror Markdown editor.
// These commands use the syntax tree to detect tables and manipulate them
// using the pure utility functions from tableUtils.ts.
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { parseTable, serializeTable, addRow, addColumn, deleteRow, deleteColumn } from '../utils/markdown/tableUtils';
// Check if a pipe at position `i` in `text` is unescaped.
// A pipe is escaped if preceded by an odd number of backslashes.
const isUnescapedPipe = (text: string, i: number): boolean => {
if (text[i] !== '|') return false;
let backslashes = 0;
let j = i - 1;
while (j >= 0 && text[j] === '\\') {
backslashes++;
j--;
}
return backslashes % 2 === 0;
};
// ---------------------------------------------------------------------------
// Table context detection
// ---------------------------------------------------------------------------
export interface TableRange {
// Start position of the table in the document
from: number;
// End position of the table in the document
to: number;
// Raw table text
text: string;
}
export interface CellLocation {
// Row index (0 = header, 1+ = body rows)
row: number;
// Column index (0-based)
col: number;
}
// Find the full table range if the cursor is inside a table.
// Walks up the syntax tree to find TableHeader, TableRow, or TableDelimiter,
// then expands to cover the entire table.
export const getTableRangeAtCursor = (state: EditorState): TableRange | null => {
const pos = state.selection.main.head;
const tree = syntaxTree(state);
let node = tree.resolveInner(pos, -1);
// Walk up to find a table-related node
while (node) {
if (node.name === 'Table') break;
if (node.name === 'TableHeader' || node.name === 'TableRow' || node.name === 'TableDelimiter') {
// Go to parent Table node
if (node.parent && node.parent.name === 'Table') {
node = node.parent;
break;
}
}
if (!node.parent || node.parent.name === 'Document') return null;
node = node.parent;
}
if (node.name !== 'Table') return null;
return {
from: node.from,
to: node.to,
text: state.doc.sliceString(node.from, node.to),
};
};
// Determine which cell (row, col) the cursor is in within a table.
// Row 0 = header row, row 1+ = body rows (skipping the delimiter row).
export const getCellAtCursor = (state: EditorState, tableRange: TableRange): CellLocation | null => {
const pos = state.selection.main.head;
const tableText = tableRange.text;
const lines = tableText.split('\n');
let currentPos = tableRange.from;
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const lineStart = currentPos;
const lineEnd = currentPos + lines[lineIdx].length;
if (pos >= lineStart && pos <= lineEnd) {
// Skip the delimiter line (line index 1)
if (lineIdx === 1) return null;
// Map lineIdx to row: 0 → header (row 0), 2+ → body (row 1+)
const row = lineIdx === 0 ? 0 : lineIdx - 1;
// Find which column the cursor is in by counting unescaped pipes
const offsetInLine = pos - lineStart;
const lineText = lines[lineIdx];
const hasLeadingPipe = lineText.trimStart().startsWith('|');
// If the line has a leading pipe, cursor before it is col -1 (not in a cell yet).
// If no leading pipe, cursor starts in col 0 (already in the first cell).
let col = hasLeadingPipe ? -1 : 0;
for (let i = 0; i < offsetInLine; i++) {
if (isUnescapedPipe(lineText, i)) {
col++;
}
}
if (col < 0) col = 0;
// Count total columns: number of unescaped pipes + 1 if no leading pipe,
// or pipes - 1 if there are leading/trailing pipes
let totalPipes = 0;
for (let i = 0; i < lineText.length; i++) {
if (isUnescapedPipe(lineText, i)) {
totalPipes++;
}
}
const hasTrailingPipe = lineText.trimEnd().endsWith('|');
let totalCols: number;
if (hasLeadingPipe && hasTrailingPipe) {
totalCols = Math.max(1, totalPipes - 1);
} else if (!hasLeadingPipe && !hasTrailingPipe) {
totalCols = totalPipes + 1;
} else {
totalCols = totalPipes;
}
if (col >= totalCols) col = totalCols - 1;
return { row, col };
}
// +1 for the newline character
currentPos = lineEnd + 1;
}
return null;
};
// Find the document offset for the content of a specific cell.
// Returns the position right after the leading space inside the cell.
export const getCellContentPosition = (
_state: EditorState,
tableRange: TableRange,
row: number,
col: number,
): number | null => {
const lines = tableRange.text.split('\n');
// Map row to line index: row 0 → line 0, row 1+ → line (row+1), skipping delimiter
const lineIdx = row === 0 ? 0 : row + 1;
if (lineIdx >= lines.length) return null;
const lineText = lines[lineIdx];
const hasLeadingPipe = lineText.trimStart().startsWith('|');
let charIdx = 0;
if (hasLeadingPipe) {
// Standard format: | A | B | — find the (col)th pipe and skip past it
let pipeCount = 0;
for (; charIdx < lineText.length; charIdx++) {
if (isUnescapedPipe(lineText, charIdx)) {
if (pipeCount === col) {
charIdx++;
while (charIdx < lineText.length && lineText[charIdx] === ' ') charIdx++;
break;
}
pipeCount++;
}
}
} else {
// No leading pipe: A | B — cells are separated by pipes
if (col === 0) {
// First cell starts at the beginning of the line (skip whitespace)
while (charIdx < lineText.length && lineText[charIdx] === ' ') charIdx++;
} else {
// Find the (col-1)th pipe separator and skip past it
let pipeCount = 0;
for (; charIdx < lineText.length; charIdx++) {
if (isUnescapedPipe(lineText, charIdx)) {
if (pipeCount === col - 1) {
charIdx++;
while (charIdx < lineText.length && lineText[charIdx] === ' ') charIdx++;
break;
}
pipeCount++;
}
}
}
}
// Calculate absolute position
let lineStart = tableRange.from;
for (let i = 0; i < lineIdx; i++) {
lineStart += lines[i].length + 1; // +1 for newline
}
return lineStart + charIdx;
};
// Get the total number of rows and columns in a table.
const getTableDimensions = (tableRange: TableRange): { rows: number; cols: number } | null => {
const table = parseTable(tableRange.text);
if (!table) return null;
return {
rows: 1 + table.body.length, // header + body rows
cols: table.header.cells.length,
};
};
// ---------------------------------------------------------------------------
// Table editing commands
// ---------------------------------------------------------------------------
// Replace the table text in the document with new table text,
// and move the cursor to the specified cell.
const replaceTableAndMoveCursor = (
view: EditorView,
tableRange: TableRange,
newTableText: string,
targetRow: number,
targetCol: number,
): boolean => {
view.dispatch({
changes: {
from: tableRange.from,
to: tableRange.to,
insert: newTableText,
},
});
// After dispatching, find the new cell position
const newRange: TableRange = {
from: tableRange.from,
to: tableRange.from + newTableText.length,
text: newTableText,
};
const cellPos = getCellContentPosition(view.state, newRange, targetRow, targetCol);
if (cellPos !== null) {
view.dispatch({
selection: { anchor: cellPos, head: cellPos },
});
}
return true;
};
// Add a row below the current cursor position.
export const tableAddRow = (view: EditorView): boolean => {
const tableRange = getTableRangeAtCursor(view.state);
if (!tableRange) return false;
const table = parseTable(tableRange.text);
if (!table) return false;
const cell = getCellAtCursor(view.state, tableRange);
if (!cell) return false;
// Determine which body row to insert after
// row 0 = header → insert at body index 0 (afterIndex = -1)
// row 1 = first body row → insert after body index 0
const bodyRowIndex = cell.row === 0 ? -1 : cell.row - 1;
const newTable = addRow(table, bodyRowIndex);
const newText = serializeTable(newTable);
// Move cursor to the new row
const newRowIndex = cell.row === 0 ? 1 : cell.row + 1;
return replaceTableAndMoveCursor(view, tableRange, newText, newRowIndex, 0);
};
// Add a column to the right of the current cursor position.
export const tableAddColumn = (view: EditorView): boolean => {
const tableRange = getTableRangeAtCursor(view.state);
if (!tableRange) return false;
const table = parseTable(tableRange.text);
if (!table) return false;
const cell = getCellAtCursor(view.state, tableRange);
if (!cell) return false;
const newTable = addColumn(table, cell.col);
const newText = serializeTable(newTable);
return replaceTableAndMoveCursor(view, tableRange, newText, cell.row, cell.col + 1);
};
// Delete the current row.
// If the cursor is on the header row, the entire table is deleted.
// If deleting the row would leave no body rows, the table is still valid (header only).
export const tableDeleteRow = (view: EditorView): boolean => {
const tableRange = getTableRangeAtCursor(view.state);
if (!tableRange) return false;
const table = parseTable(tableRange.text);
if (!table) return false;
const cell = getCellAtCursor(view.state, tableRange);
if (!cell) return false;
if (cell.row === 0) {
// Delete entire table when on header row
view.dispatch({
changes: { from: tableRange.from, to: tableRange.to, insert: '' },
});
return true;
}
const bodyRowIndex = cell.row - 1;
const newTable = deleteRow(table, bodyRowIndex);
if (!newTable) return false;
const newText = serializeTable(newTable);
// Move cursor to previous row or header
const newRow = Math.min(cell.row, newTable.body.length);
return replaceTableAndMoveCursor(view, tableRange, newText, newRow, cell.col);
};
// Delete the current column.
// Returns false (no-op) if it would delete the last column.
export const tableDeleteColumn = (view: EditorView): boolean => {
const tableRange = getTableRangeAtCursor(view.state);
if (!tableRange) return false;
const table = parseTable(tableRange.text);
if (!table) return false;
const cell = getCellAtCursor(view.state, tableRange);
if (!cell) return false;
const newTable = deleteColumn(table, cell.col);
if (!newTable) return false;
const newText = serializeTable(newTable);
// Move cursor to previous column or stay at same position
const newCol = Math.min(cell.col, newTable.header.cells.length - 1);
return replaceTableAndMoveCursor(view, tableRange, newText, cell.row, newCol);
};
// Move the cursor to the next cell (Tab behavior).
// If at the last cell of the last row, add a new row.
export const tableNextCell = (view: EditorView): boolean => {
const tableRange = getTableRangeAtCursor(view.state);
if (!tableRange) return false;
const dims = getTableDimensions(tableRange);
if (!dims) return false;
const cell = getCellAtCursor(view.state, tableRange);
if (!cell) return false;
let nextRow = cell.row;
let nextCol = cell.col + 1;
if (nextCol >= dims.cols) {
nextCol = 0;
nextRow++;
}
// If we've gone past the last row, add a new row
if (nextRow >= dims.rows) {
const table = parseTable(tableRange.text);
if (!table) return false;
const newTable = addRow(table, table.body.length - 1);
const newText = serializeTable(newTable);
return replaceTableAndMoveCursor(view, tableRange, newText, nextRow, 0);
}
// Move cursor to the target cell
const cellPos = getCellContentPosition(view.state, tableRange, nextRow, nextCol);
if (cellPos === null) return false;
view.dispatch({
selection: { anchor: cellPos, head: cellPos },
});
return true;
};
// Move the cursor to the previous cell (Shift+Tab behavior).
export const tablePreviousCell = (view: EditorView): boolean => {
const tableRange = getTableRangeAtCursor(view.state);
if (!tableRange) return false;
const dims = getTableDimensions(tableRange);
if (!dims) return false;
const cell = getCellAtCursor(view.state, tableRange);
if (!cell) return false;
let prevRow = cell.row;
let prevCol = cell.col - 1;
if (prevCol < 0) {
prevRow--;
prevCol = dims.cols - 1;
}
// Can't go before the first cell of the header
if (prevRow < 0) return false;
const cellPos = getCellContentPosition(view.state, tableRange, prevRow, prevCol);
if (cellPos === null) return false;
view.dispatch({
selection: { anchor: cellPos, head: cellPos },
});
return true;
};