// 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; };