/** * blockOperations.ts - Block操作工具函数 * * 提供block的各种操作辅助函数 * * @module utils/blockOperations */ import type { DocumentBlock, BlockType, TableBlock, TableRow, TableCell, RichText, } from '../types/editor'; // ══════════════════════════════════════════════════════════════════════════════ // Block ID Generation // ══════════════════════════════════════════════════════════════════════════════ /** * 生成块ID * * @param type 块类型 * @param index 索引(可选) * @returns 块ID * * @example * ```ts * generateBlockId('heading') // "block-h-1704096000000-0" * ``` */ export function generateBlockId(type: BlockType, index: number = 0): string { const typePrefix: Record = { heading: 'h', paragraph: 'p', table: 'tbl', image: 'img', toc: 'toc', }; return `block-${typePrefix[type]}-${Date.now()}-${index}`; } // ══════════════════════════════════════════════════════════════════════════════ // Block Order Operations // ══════════════════════════════════════════════════════════════════════════════ /** * 计算插入位置的block_order * 稀疏排序策略:在两个块之间找到中间值 * * @param prevOrder 前一个块的order * @param nextOrder 后一个块的order * @returns 新的order,如果返回-1表示需要重排 * * @example * ```ts * computeInsertOrder(100, 200) // 150 * computeInsertOrder(100, 101) // -1 (需要重排) * ``` */ export function computeInsertOrder(prevOrder: number, nextOrder: number): number { const gap = nextOrder - prevOrder; if (gap > 1) { // 有间隙,直接取中间值 return Math.floor((prevOrder + nextOrder) / 2); } // 间隙不足,需要重排 return -1; } /** * 重新平衡block_order(稀疏排序,间隔100) * * @param blocks 块数组 * @returns 重新排序后的块数组 * * @example * ```ts * const rebalanced = rebalanceBlockOrders(blocks); * // blocks[0].block_order = 0 * // blocks[1].block_order = 100 * // blocks[2].block_order = 200 * ``` */ export function rebalanceBlockOrders(blocks: DocumentBlock[]): DocumentBlock[] { const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order); return sorted.map((block, index) => ({ ...block, block_order: index * 100, })); } // ══════════════════════════════════════════════════════════════════════════════ // Table Operations // ══════════════════════════════════════════════════════════════════════════════ function assertTableIndex(index: number, length: number, label: string): void { if (!Number.isInteger(index) || index < 0 || index >= length) { throw new Error(`${label}索引无效`); } } function assertTableRange( table: TableBlock, startRow: number, startCol: number, endRow: number, endCol: number, ): void { assertTableIndex(startRow, table.content.rows.length, '起始行'); assertTableIndex(endRow, table.content.rows.length, '结束行'); if (startRow > endRow) throw new Error('行范围无效'); assertTableIndex(startCol, table.metadata.cols, '起始列'); assertTableIndex(endCol, table.metadata.cols, '结束列'); if (startCol > endCol) throw new Error('列范围无效'); const selectedRows = table.content.rows.slice(startRow, endRow + 1); if (selectedRows.some((row) => row.cells.length <= endCol)) { throw new Error('表格单元格结构无效'); } } export interface TableVisualCellPosition { rowStart: number; rowEnd: number; colStart: number; colEnd: number; } export interface TableCellReference { rowIndex: number; cellIndex: number; visual: TableVisualCellPosition; } export function getTableVisualCellPositions(table: TableBlock): Map { const occupied: boolean[][] = []; const positions = new Map(); table.content.rows.forEach((row, rowIndex) => { if (!occupied[rowIndex]) occupied[rowIndex] = []; let visualCol = 0; row.cells.forEach((cell, cellIndex) => { const rowSpan = cell.rowspan ?? 1; const colSpan = cell.colspan ?? 1; if (rowSpan <= 0 || colSpan <= 0) { const hiddenCol = Number.isInteger(cell.col_index) && (cell.col_index ?? 0) > 0 ? (cell.col_index ?? 1) - 1 : visualCol; visualCol = Math.max(visualCol, hiddenCol + 1); return; } while (occupied[rowIndex][visualCol]) visualCol += 1; const position = { rowStart: rowIndex, rowEnd: rowIndex + rowSpan - 1, colStart: visualCol, colEnd: visualCol + colSpan - 1, }; positions.set(`${rowIndex}-${cellIndex}`, position); for (let occupiedRow = position.rowStart; occupiedRow <= position.rowEnd; occupiedRow += 1) { if (!occupied[occupiedRow]) occupied[occupiedRow] = []; for (let occupiedCol = position.colStart; occupiedCol <= position.colEnd; occupiedCol += 1) { occupied[occupiedRow][occupiedCol] = true; } } visualCol = position.colEnd + 1; }); }); return positions; } export function getTableCellReference( table: TableBlock, rowIndex: number, cellIndex: number, positions: Map = getTableVisualCellPositions(table), ): TableCellReference | null { const visual = positions.get(`${rowIndex}-${cellIndex}`); if (!visual) return null; return { rowIndex, cellIndex, visual }; } export function getTableCellsForVisualBounds( table: TableBlock, bounds: TableVisualCellPosition, ): TableCell[] { const positions = getTableVisualCellPositions(table); const cells: TableCell[] = []; for (const [key, position] of positions.entries()) { const intersects = position.rowStart <= bounds.rowEnd && position.rowEnd >= bounds.rowStart && position.colStart <= bounds.colEnd && position.colEnd >= bounds.colStart; if (!intersects) continue; const [rowIndex, cellIndex] = key.split('-').map(Number); const cell = table.content.rows[rowIndex]?.cells[cellIndex]; if (cell) cells.push(cell); } return cells; } export function validateTableStructure(table: TableBlock): boolean { const columnCount = table.metadata?.cols; const rowCount = table.metadata?.rows; const rows = table.content?.rows; if (!Number.isInteger(columnCount) || columnCount <= 0 || columnCount > 1000) return false; if (!Number.isInteger(rowCount) || rowCount <= 0 || rowCount > 1000) return false; if (!Array.isArray(rows) || rows.length !== rowCount) return false; if (!Array.isArray(table.metadata.col_widths) || table.metadata.col_widths.length !== columnCount) { return false; } if (table.content.col_widths && table.content.col_widths.length !== columnCount) return false; if (table.metadata.col_widths.some((width) => !Number.isFinite(width) || width <= 0)) return false; if (table.content.col_widths?.some((width) => !Number.isFinite(width) || width <= 0)) return false; for (const row of rows) { if (!Array.isArray(row.cells) || row.cells.length > columnCount) return false; if (row.height !== undefined && (!Number.isFinite(row.height) || row.height <= 0)) return false; for (const cell of row.cells) { const rowspan = cell.rowspan; const colspan = cell.colspan; if (!Number.isInteger(rowspan) || !Number.isInteger(colspan)) return false; const isHidden = rowspan === 0 && colspan === 0; if (isHidden) { const colIndex = cell.col_index; if (typeof colIndex !== 'number' || !Number.isInteger(colIndex) || colIndex < 1 || colIndex > columnCount) { return false; } continue; } if (rowspan < 1 || rowspan > rowCount || colspan < 1 || colspan > columnCount) return false; if (cell.width !== undefined && (!Number.isFinite(cell.width) || cell.width <= 0)) return false; } } return [...getTableVisualCellPositions(table).values()].every((position) => position.rowStart >= 0 && position.rowEnd < rowCount && position.colStart >= 0 && position.colEnd < columnCount ); } function createHiddenCell(colIndex: number): TableCell { return { text: '', rowspan: 0, colspan: 0, col_index: colIndex + 1, style: {}, word_style: 'Normal', width: 100, }; } interface PositionedTableCell { cell: TableCell; position: TableVisualCellPosition; } function rebuildTableRows( cells: PositionedTableCell[], rowHeights: Array, columnCount: number, ): TableRow[] { const starts = new Map(); for (const entry of cells) { starts.set(`${entry.position.rowStart}-${entry.position.colStart}`, entry); } return rowHeights.map((height, rowIndex) => { const rowCells: TableCell[] = []; for (let colIndex = 0; colIndex < columnCount; colIndex += 1) { const start = starts.get(`${rowIndex}-${colIndex}`); if (start) { rowCells.push({ ...start.cell, rowspan: start.position.rowEnd - start.position.rowStart + 1, colspan: start.position.colEnd - start.position.colStart + 1, col_index: colIndex + 1, }); continue; } const covered = cells.some(({ position }) => position.rowStart <= rowIndex && position.rowEnd >= rowIndex && position.colStart <= colIndex && position.colEnd >= colIndex ); rowCells.push(covered ? createHiddenCell(colIndex) : createEmptyCell(colIndex + 1)); } return { cells: rowCells, ...(height !== undefined ? { height } : {}), }; }); } export function getTableSelectionBounds( table: TableBlock, startRow: number, startCol: number, endRow: number, endCol: number, ): TableVisualCellPosition | null { if (![startRow, startCol, endRow, endCol].every(Number.isInteger)) return null; if (startRow < 0 || endRow < startRow || endRow >= table.content.rows.length) return null; const positions = getTableVisualCellPositions(table); const startPosition = positions.get(`${startRow}-${startCol}`); const endPosition = positions.get(`${endRow}-${endCol}`); if (!startPosition || !endPosition) return null; const bounds = { rowStart: Math.min(startPosition.rowStart, endPosition.rowStart), rowEnd: Math.max(startPosition.rowEnd, endPosition.rowEnd), colStart: Math.min(startPosition.colStart, endPosition.colStart), colEnd: Math.max(startPosition.colEnd, endPosition.colEnd), }; for (const position of positions.values()) { const intersects = position.rowStart <= bounds.rowEnd && position.rowEnd >= bounds.rowStart && position.colStart <= bounds.colEnd && position.colEnd >= bounds.colStart; const isContained = position.rowStart >= bounds.rowStart && position.rowEnd <= bounds.rowEnd && position.colStart >= bounds.colStart && position.colEnd <= bounds.colEnd; if (intersects && !isContained) return null; } return bounds; } export function getTableCellRangeForVisualBounds( table: TableBlock, bounds: TableVisualCellPosition, ): { startRow: number; startCol: number; endRow: number; endCol: number } | null { const positions = getTableVisualCellPositions(table); let start: { row: number; col: number } | null = null; let end: { row: number; col: number } | null = null; for (const [key, position] of positions.entries()) { if (position.rowStart !== bounds.rowStart || position.colStart !== bounds.colStart) continue; const [row, col] = key.split('-').map(Number); start = { row, col }; } for (const [key, position] of positions.entries()) { if (position.rowEnd !== bounds.rowEnd || position.colEnd !== bounds.colEnd) continue; const [row, col] = key.split('-').map(Number); end = { row, col }; } if (!start || !end) return null; return { startRow: start.row, startCol: start.col, endRow: end.row, endCol: end.col }; } /** * 将 text 字段从富文本数组转换为纯字符串 * * @param text 文本内容(字符串或富文本数组) * @returns 纯字符串 */ export function flattenTextToString(text: string | RichText[]): string { if (typeof text === 'string') { return text; } // 如果是 RichText 数组,提取所有的纯文本并连接 return text.map(seg => seg.text).join(''); } /** * 序列化表格单元格为后端格式 * 保留富文本数组,确保单元格内部格式可以跨保存恢复 * * @param cell 前端单元格数据 * @param colIndex 列索引(从1开始) * @param defaultWidth 默认宽度(磅) * @returns 序列化后的单元格(text保留字符串或富文本数组) */ export function serializeTableCell( cell: TableCell, colIndex: number, defaultWidth: number = 100 ): TableCell { // 构建样式对象,只包含有值的属性 const style: TableCell['style'] = {}; if (cell.style?.align) { style.align = cell.style.align; } if (cell.style?.font_size !== undefined && cell.style.font_size > 0) { style.font_size = cell.style.font_size; } if (cell.style?.font_name) { style.font_name = cell.style.font_name; } if (cell.style?.bold !== undefined) { style.bold = cell.style.bold; } if (cell.style?.italic !== undefined) { style.italic = cell.style.italic; } if (cell.style?.underline !== undefined) { style.underline = cell.style.underline; } if (cell.style?.color) { style.color = cell.style.color; } // 垂直对齐(表格专用) if (cell.style?.valign) { style.valign = cell.style.valign; } return { text: cell.text, rowspan: cell.rowspan ?? 1, colspan: cell.colspan ?? 1, col_index: cell.col_index !== undefined ? cell.col_index : colIndex, style: style, word_style: cell.word_style || 'Normal', width: cell.width !== undefined ? cell.width : defaultWidth, }; } /** * 序列化表格块为后端格式 * 保留单元格富文本内容,并补齐后端所需的单元格字段 * * @param table 表格块 * @returns 序列化后的表格块 */ export function serializeTableBlock(table: TableBlock): TableBlock { // 从content.col_widths计算平均宽度作为默认值 // 如果没有content.col_widths,从metadata.col_widths和table_width推算 let avgWidth = 100; if (table.content.col_widths && table.content.col_widths.length > 0) { avgWidth = table.content.col_widths.reduce((sum, w) => sum + w, 0) / table.content.col_widths.length; } else if (table.metadata.col_widths && table.metadata.col_widths.length > 0) { // 从百分比反推pt值 const tableWidthPercent = table.metadata.table_width || 100; const pageWidthPt = 478; // A4宽度减边距 const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100); avgWidth = tableActualWidthPt / table.metadata.col_widths.length; } const rows = table.content.rows.map((row) => { const serializedRow: TableRow = { cells: row.cells.map((cell, colIdx) => serializeTableCell(cell, colIdx + 1, avgWidth) ), }; // 只在有值时才添加height字段 if (row.height !== undefined) { serializedRow.height = row.height; } return serializedRow; }); return { ...table, content: { rows, // 如果content有col_widths,保留它 ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {}) }, }; } /** * 规范化表格单元格数据结构 * 确保单元格包含所有必需字段,与后端期望的格式一致 * * @param cell 原始单元格数据 * @param colIndex 列索引(从1开始) * @param defaultWidth 默认宽度(磅) * @returns 规范化后的单元格 */ export function normalizeTableCell( cell: Partial, colIndex: number, defaultWidth: number = 100 ): TableCell { // 构建样式对象,只包含有值的属性 const style: TableCell['style'] = {}; if (cell.style?.align) { style.align = cell.style.align; } if (cell.style?.font_size !== undefined && cell.style.font_size > 0) { style.font_size = cell.style.font_size; } if (cell.style?.font_name) { style.font_name = cell.style.font_name; } if (cell.style?.bold !== undefined) { style.bold = cell.style.bold; } if (cell.style?.italic !== undefined) { style.italic = cell.style.italic; } if (cell.style?.underline !== undefined) { style.underline = cell.style.underline; } if (cell.style?.color) { style.color = cell.style.color; } // 垂直对齐(表格专用) if (cell.style?.valign) { style.valign = cell.style.valign; } return { text: cell.text || '', rowspan: cell.rowspan ?? 1, colspan: cell.colspan ?? 1, col_index: cell.col_index !== undefined ? cell.col_index : colIndex, style: style, word_style: cell.word_style || 'Normal', width: cell.width !== undefined ? cell.width : defaultWidth, }; } /** * 规范化整个表格的数据结构 * 确保所有单元格都包含完整的字段信息 * * @param table 表格块 * @returns 规范化后的表格块 */ export function normalizeTableBlock(table: TableBlock): TableBlock { // 从content.col_widths计算平均宽度作为默认值 let avgWidth = 100; if (table.content.col_widths && table.content.col_widths.length > 0) { avgWidth = table.content.col_widths.reduce((sum, w) => sum + w, 0) / table.content.col_widths.length; } else if (table.metadata.col_widths && table.metadata.col_widths.length > 0) { // 从百分比反推pt值 const tableWidthPercent = table.metadata.table_width || 100; const pageWidthPt = 478; // A4宽度减边距 const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100); avgWidth = tableActualWidthPt / table.metadata.col_widths.length; } const rows = table.content.rows.map((row) => { const normalizedRow: TableRow = { cells: row.cells.map((cell, colIdx) => normalizeTableCell(cell, colIdx + 1, avgWidth) ), }; // 只在有值时才添加height字段 if (row.height !== undefined) { normalizedRow.height = row.height; } return normalizedRow; }); return { ...table, content: { rows, // 如果content有col_widths,保留它 ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {}) }, }; } /** * 创建空的表格单元格 * * @param colIndex 列索引(从1开始,可选) * @param width 单元格宽度(磅,可选) * @returns 空单元格,使用最小化的样式 */ export function createEmptyCell(colIndex?: number, width?: number): TableCell { return { text: '', rowspan: 1, colspan: 1, col_index: colIndex, style: {}, // 空样式对象,让后端或显示层使用默认值 word_style: 'Normal', width: width !== undefined ? width : 100, }; } /** * 创建空的表格行 * * @param cols 列数 * @param colWidths 列宽数组(磅,可选) * @param height 行高(磅,可选,默认58) * @returns 表格行 */ export function createEmptyRow(cols: number, colWidths?: number[], height?: number): TableRow { return { cells: Array(cols).fill(null).map((_, index) => createEmptyCell( index + 1, // col_index从1开始 colWidths?.[index] ) ), height: height !== undefined ? height : 58, // 默认行高58磅 }; } /** * 在表格中插入行 * * @param table 表格块 * @param afterRow 在此行之后插入 * @returns 新的表格块 * * @example * ```ts * const newTable = insertTableRow(table, 1); // 在第2行后插入 * ``` */ function insertTableRowAt(table: TableBlock, insertIndex: number): TableBlock { if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > table.content.rows.length) { throw new Error('行索引无效'); } // 使用合理的默认行高 const defaultHeight = 58; // 默认行高58磅 // 计算列宽:从content.col_widths或从metadata推算 let colWidths: number[]; if (table.content.col_widths && table.content.col_widths.length > 0) { colWidths = table.content.col_widths; } else if (table.metadata.col_widths && table.metadata.col_widths.length > 0) { // 从百分比反推pt值 const tableWidthPercent = table.metadata.table_width || 100; const pageWidthPt = 478; // A4宽度减边距 const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100); colWidths = table.metadata.col_widths.map(percent => (tableActualWidthPt * percent / 100) ); } else { // 没有列宽信息,使用默认值 colWidths = Array(table.metadata.cols).fill(100); } const newRow = createEmptyRow(table.metadata.cols, colWidths, defaultHeight); const positions = getTableVisualCellPositions(table); const rows = table.content.rows.map((row, rowIndex) => ({ ...row, cells: row.cells.map((cell, cellIndex) => { const position = positions.get(`${rowIndex}-${cellIndex}`); if (!position || position.rowStart >= insertIndex || position.rowEnd < insertIndex) return cell; return { ...cell, rowspan: (cell.rowspan ?? 1) + 1 }; }), })); newRow.cells = newRow.cells.map((cell, colIndex) => { const spanningCell = [...positions.values()].find((position) => position.rowStart < insertIndex && position.rowEnd >= insertIndex && position.colStart <= colIndex && position.colEnd >= colIndex ); return spanningCell ? createHiddenCell(colIndex) : cell; }); rows.splice(insertIndex, 0, newRow); return { ...table, content: { rows, // 保留col_widths如果存在 ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {}) }, metadata: { ...table.metadata, rows: rows.length, }, }; } export function insertTableRow(table: TableBlock, afterRow: number): TableBlock { assertTableIndex(afterRow, table.content.rows.length, '行'); return insertTableRowAt(table, afterRow + 1); } /** * 在表格中插入行 * * @param table 表格块 * @param beforeRow 在此行之前插入 * @returns 新的表格块 */ export function insertTableRowBefore(table: TableBlock, beforeRow: number): TableBlock { assertTableIndex(beforeRow, table.content.rows.length, '行'); return insertTableRowAt(table, beforeRow); } /** * 在表格中插入列 * * @param table 表格块 * @param afterCol 在此列之后插入 * @returns 新的表格块 * * @example * ```ts * const newTable = insertTableColumn(table, 1); // 在第2列后插入 * ``` */ function insertTableColumnAt(table: TableBlock, insertIndex: number): TableBlock { if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > table.metadata.cols) { throw new Error('列索引无效'); } // 计算新列的宽度 // 如果有content.col_widths,使用相邻列的宽度;否则使用默认值 let newColWidth = 100; // 默认列宽100磅 const adjacentCol = Math.min(insertIndex, Math.max(table.metadata.cols - 1, 0)); if (table.content.col_widths && table.content.col_widths.length > adjacentCol) { newColWidth = table.content.col_widths[adjacentCol]; } else if (table.metadata.col_widths && table.metadata.col_widths.length > adjacentCol) { // 从百分比反推pt值 const tableWidthPercent = table.metadata.table_width || 100; const pageWidthPt = 478; const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100); newColWidth = tableActualWidthPt * table.metadata.col_widths[adjacentCol] / 100; } const positions = getTableVisualCellPositions(table); const positionedCells: PositionedTableCell[] = []; table.content.rows.forEach((row, rowIndex) => { row.cells.forEach((cell, cellIndex) => { const position = positions.get(`${rowIndex}-${cellIndex}`); if (!position) return; positionedCells.push({ cell, position: position.colStart < insertIndex && position.colEnd >= insertIndex ? { ...position, colEnd: position.colEnd + 1 } : position.colStart >= insertIndex ? { ...position, colStart: position.colStart + 1, colEnd: position.colEnd + 1 } : position, }); }); }); const rows = rebuildTableRows( positionedCells, table.content.rows.map((row) => row.height), table.metadata.cols + 1, ); // 更新metadata.col_widths(百分比) const metadataColWidths = [...table.metadata.col_widths]; // 新列使用相邻列的百分比,如果没有则平均分配 const newColPercent = metadataColWidths[adjacentCol] || (100 / (metadataColWidths.length + 1)); metadataColWidths.splice(insertIndex, 0, newColPercent); // 更新content.col_widths(pt单位) const contentColWidths = table.content.col_widths ? [...table.content.col_widths] : []; if (contentColWidths.length > 0) { contentColWidths.splice(insertIndex, 0, newColWidth); } return { ...table, content: { rows, ...(contentColWidths.length > 0 ? { col_widths: contentColWidths } : {}) }, metadata: { ...table.metadata, cols: table.metadata.cols + 1, col_widths: metadataColWidths, }, }; } export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock { assertTableIndex(afterCol, table.metadata.cols, '列'); return insertTableColumnAt(table, afterCol + 1); } /** * 在表格中插入列 * * @param table 表格块 * @param beforeCol 在此列之前插入 * @returns 新的表格块 */ export function insertTableColumnBefore(table: TableBlock, beforeCol: number): TableBlock { assertTableIndex(beforeCol, table.metadata.cols, '列'); return insertTableColumnAt(table, beforeCol); } /** * 删除表格行 * * @param table 表格块 * @param rowIndex 行索引 * @returns 新的表格块 */ export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock { assertTableIndex(rowIndex, table.content.rows.length, '行'); if (table.content.rows.length <= 1) { throw new Error('表格至少需要一行'); } const positions = getTableVisualCellPositions(table); const positionedCells: PositionedTableCell[] = []; table.content.rows.forEach((row, sourceRow) => { row.cells.forEach((cell, sourceCol) => { const position = positions.get(`${sourceRow}-${sourceCol}`); if (!position) return; if (position.rowStart <= rowIndex && position.rowEnd >= rowIndex) { if (position.rowStart === position.rowEnd) return; positionedCells.push({ cell, position: { ...position, rowStart: position.rowStart > rowIndex ? position.rowStart - 1 : position.rowStart, rowEnd: position.rowEnd - 1, }, }); return; } positionedCells.push({ cell, position: { ...position, rowStart: position.rowStart > rowIndex ? position.rowStart - 1 : position.rowStart, rowEnd: position.rowEnd > rowIndex ? position.rowEnd - 1 : position.rowEnd, }, }); }); }); const rowHeights = table.content.rows .filter((_, index) => index !== rowIndex) .map((row) => row.height); const rows = rebuildTableRows(positionedCells, rowHeights, table.metadata.cols); return { ...table, content: { rows, // 保留col_widths如果存在 ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {}) }, metadata: { ...table.metadata, rows: rows.length, }, }; } /** * 删除表格列 * * @param table 表格块 * @param colIndex 列索引 * @returns 新的表格块 */ export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlock { assertTableIndex(colIndex, table.metadata.cols, '列'); if (table.metadata.cols <= 1) { throw new Error('表格至少需要一列'); } const positions = getTableVisualCellPositions(table); const positionedCells: PositionedTableCell[] = []; table.content.rows.forEach((row, sourceRow) => { row.cells.forEach((cell, sourceCol) => { const position = positions.get(`${sourceRow}-${sourceCol}`); if (!position) return; if (position.colStart <= colIndex && position.colEnd >= colIndex) { if (position.colStart === position.colEnd) return; positionedCells.push({ cell, position: { ...position, colStart: position.colStart > colIndex ? position.colStart - 1 : position.colStart, colEnd: position.colEnd - 1, }, }); return; } positionedCells.push({ cell, position: { ...position, colStart: position.colStart > colIndex ? position.colStart - 1 : position.colStart, colEnd: position.colEnd > colIndex ? position.colEnd - 1 : position.colEnd, }, }); }); }); const rows = rebuildTableRows( positionedCells, table.content.rows.map((row) => row.height), table.metadata.cols - 1, ); // 更新metadata.col_widths const metadataColWidths = table.metadata.col_widths.filter((_, i) => i !== colIndex); // 更新content.col_widths const contentColWidths = table.content.col_widths ? table.content.col_widths.filter((_, i) => i !== colIndex) : []; return { ...table, content: { rows, ...(contentColWidths.length > 0 ? { col_widths: contentColWidths } : {}) }, metadata: { ...table.metadata, cols: table.metadata.cols - 1, col_widths: metadataColWidths, }, }; } /** * 合并单元格 * * 支持横向合并(colspan)和纵向合并(rowspan) * 合并后的主单元格会保存所有被合并单元格的文本内容(用空格连接) * 被合并的单元格会被标记为隐藏(rowspan=0, colspan=0) * * 重要:与后端逻辑保持一致 * - 主单元格(左上角)保留完整的字段信息 * - 被合并的单元格标记为rowspan=0, colspan=0, text='' * - 保留所有单元格的col_index * - 合并后的文本在包含富文本时保留片段样式 * * @param table 表格块 * @param startRow 起始行 * @param startCol 起始列 * @param endRow 结束行 * @param endCol 结束列 * @returns 新的表格块 * * @example * ```ts * // 横向合并: a + b (第0行,第0-1列) * mergeCells(table, 0, 0, 0, 1); * // 结果: a单元格 colspan=2, text包含a和b的内容 * * // 纵向合并: a + d (第0-1行,第0列) * mergeCells(table, 0, 0, 1, 0); * // 结果: a单元格 rowspan=2, text包含a和d的内容 * ``` */ export function mergeCells( table: TableBlock, startRow: number, startCol: number, endRow: number, endCol: number ): TableBlock { assertTableRange(table, startRow, startCol, endRow, endCol); const selectionBounds = getTableSelectionBounds(table, startRow, startCol, endRow, endCol); if (!selectionBounds) { throw new Error('合并范围不能截断已有合并单元格'); } return mergeCellsByVisualBounds(table, selectionBounds); } /** * 按视觉网格边界合并单元格。 * * 视觉坐标与行内 cells 数组下标不是同一个坐标系,尤其在跨行单元格 * 产生隐藏占位时,不能先把视觉范围当作数组范围再次校验。 */ export function mergeCellsByVisualBounds( table: TableBlock, selectionBounds: TableVisualCellPosition, ): TableBlock { if (selectionBounds.rowStart > selectionBounds.rowEnd) { throw new Error('行范围无效'); } if (selectionBounds.colStart > selectionBounds.colEnd) { throw new Error('列范围无效'); } const visualPositions = getTableVisualCellPositions(table); const visualColumnEnd = Math.max( table.metadata.cols - 1, ...[...visualPositions.values()].map((position) => position.colEnd), ); const visualRowEnd = Math.max( table.content.rows.length - 1, ...[...visualPositions.values()].map((position) => position.rowEnd), ); if ( selectionBounds.rowStart < 0 || selectionBounds.rowEnd > visualRowEnd || selectionBounds.colStart < 0 || selectionBounds.colEnd > visualColumnEnd ) { throw new Error('结束索引无效'); } for (const position of visualPositions.values()) { const intersects = position.rowStart <= selectionBounds.rowEnd && position.rowEnd >= selectionBounds.rowStart && position.colStart <= selectionBounds.colEnd && position.colEnd >= selectionBounds.colStart; const isContained = position.rowStart >= selectionBounds.rowStart && position.rowEnd <= selectionBounds.rowEnd && position.colStart >= selectionBounds.colStart && position.colEnd <= selectionBounds.colEnd; if (intersects && !isContained) { throw new Error('合并范围不能截断已有合并单元格'); } } const primaryEntry = [...visualPositions.entries()].find(([, position]) => position.rowStart === selectionBounds.rowStart && position.colStart === selectionBounds.colStart ); if (!primaryEntry) { throw new Error('合并起始单元格不存在'); } const primaryKey = primaryEntry[0]; // 计算合并范围 const rowSpan = selectionBounds.rowEnd - selectionBounds.rowStart + 1; const colSpan = selectionBounds.colEnd - selectionBounds.colStart + 1; // 收集所有被合并单元格的文本内容 const displayTexts: string[] = []; const mergedRichText: RichText[] = []; let hasRichText = false; // 辅助函数:将 text 字段规范化为纯字符串 const normalizeText = (text: string | RichText[]): string => { if (typeof text === 'string') { return text; } // 如果是 RichText 数组,提取所有的纯文本 return text.map(seg => seg.text).join(''); }; // 收集文本 table.content.rows.forEach((row, rowIdx) => { row.cells.forEach((cell, colIdx) => { const position = visualPositions.get(`${rowIdx}-${colIdx}`); if (!position) return; const isContained = position.rowStart >= selectionBounds.rowStart && position.rowEnd <= selectionBounds.rowEnd && position.colStart >= selectionBounds.colStart && position.colEnd <= selectionBounds.colEnd; if (isContained) { const text = normalizeText(cell.text); if (text.trim()) { displayTexts.push(text.trim()); if (Array.isArray(cell.text)) { if (!hasRichText) { displayTexts.slice(0, -1).forEach((previousText) => { if (mergedRichText.length > 0) { mergedRichText.push({ text: ' ', style: {} }); } mergedRichText.push({ text: previousText, style: {} }); }); } hasRichText = true; if (mergedRichText.length > 0) { mergedRichText.push({ text: ' ', style: {} }); } mergedRichText.push(...cell.text); } else if (hasRichText) { if (mergedRichText.length > 0) { mergedRichText.push({ text: ' ', style: {} }); } mergedRichText.push({ text: text.trim(), style: {} }); } } } }); }); const rows = table.content.rows.map((row, rowIdx) => { const cells = row.cells.map((cell, colIdx) => { const key = `${rowIdx}-${colIdx}`; const position = visualPositions.get(key); if (!position) return cell; const isContained = position.rowStart >= selectionBounds.rowStart && position.rowEnd <= selectionBounds.rowEnd && position.colStart >= selectionBounds.colStart && position.colEnd <= selectionBounds.colEnd; if (!isContained) return cell; if (key === primaryKey) { // 主单元格:设置完整的标准格式 // 合并后的文本在包含富文本时保留片段样式 const mergedText = displayTexts.join(' '); const mergedContent = hasRichText ? mergedRichText : mergedText; // 构建标准格式的单元格对象 return { text: mergedContent.length > 0 ? mergedContent : normalizeText(cell.text), rowspan: rowSpan, colspan: colSpan, col_index: colIdx + 1, // 列索引从1开始 style: { ...cell.style, // 保留原有样式 }, word_style: cell.word_style || 'Normal', width: cell.width !== undefined ? cell.width : 100, }; } // 被合并的单元格,标记为隐藏 return { text: '', // 清空显示内容 rowspan: 0, colspan: 0, col_index: colIdx + 1, // 保持列索引 style: cell.style || {}, word_style: cell.word_style || 'Normal', width: cell.width, }; }); return { cells, height: row.height, // 保留行高 }; }); return { ...table, content: { rows, // 保留col_widths如果存在 ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {}) }, }; } /** * 拆分单元格 * * 将已合并的单元格拆分回独立单元格 * 主单元格保留原有内容,其他单元格恢复为空单元格 * * 关键逻辑: * 1. 对于横向合并(colspan>1),需要在当前行**插入**新的单元格 * 2. 对于纵向合并(rowspan>1),需要恢复被隐藏的单元格(rowspan=0, colspan=0) * 3. 对于同时横向和纵向合并,需要同时处理插入和恢复 * * 与后端逻辑保持一致: * - 主单元格保留原有text和样式 * - 新单元格使用空text和默认样式 * - 保留所有单元格的width * - 正确更新col_index * * @param table 表格块 * @param rowIndex 单元格所在行 * @param colIndex 单元格所在列 * @returns 新的表格块 * * @example * ```ts * // 拆分横向合并的单元格 (a+b) * splitCell(table, 0, 0); * // 结果: a单元格保留内容,b单元格变为空单元格 * * // 拆分纵向合并的单元格 (a+d) * splitCell(table, 0, 0); * // 结果: a单元格保留内容,d单元格变为空单元格 * ``` */ export function splitCell( table: TableBlock, rowIndex: number, colIndex: number ): TableBlock { assertTableIndex(rowIndex, table.content.rows.length, '行'); assertTableIndex(colIndex, table.metadata.cols, '列'); const targetCell = table.content.rows[rowIndex]?.cells[colIndex]; if (!targetCell) { throw new Error('单元格不存在'); } // 如果单元格没有合并,无需拆分 if (targetCell.rowspan <= 1 && targetCell.colspan <= 1) { throw new Error('此单元格未合并,无需拆分'); } const positions = getTableVisualCellPositions(table); const targetPosition = positions.get(`${rowIndex}-${colIndex}`); if (!targetPosition) { throw new Error('合并单元格位置无效'); } const positionedCells: PositionedTableCell[] = []; for (const [key, position] of positions.entries()) { const [currentRow, currentCol] = key.split('-').map(Number); const cell = table.content.rows[currentRow]?.cells[currentCol]; if (!cell) continue; if (currentRow !== rowIndex || currentCol !== colIndex) { positionedCells.push({ cell, position }); continue; } for (let splitRow = targetPosition.rowStart; splitRow <= targetPosition.rowEnd; splitRow += 1) { for (let splitCol = targetPosition.colStart; splitCol <= targetPosition.colEnd; splitCol += 1) { const isPrimary = splitRow === targetPosition.rowStart && splitCol === targetPosition.colStart; positionedCells.push({ cell: isPrimary ? { ...cell, rowspan: 1, colspan: 1, col_index: splitCol + 1, word_style: cell.word_style || 'Normal', } : createEmptyCell(splitCol + 1, cell.width || 100), position: { rowStart: splitRow, rowEnd: splitRow, colStart: splitCol, colEnd: splitCol, }, }); } } } const rows = rebuildTableRows( positionedCells, table.content.rows.map((row) => row.height), table.metadata.cols, ); return { ...table, content: { rows, // 保留col_widths如果存在 ...(table.content.col_widths ? { col_widths: table.content.col_widths } : {}) }, }; } // ══════════════════════════════════════════════════════════════════════════════ // Block Validation // ══════════════════════════════════════════════════════════════════════════════ /** * 验证块数据是否有效 * * @param block 块数据 * @returns 是否有效 */ export function validateBlock(block: Partial): boolean { if (!block.type) return false; if (block.block_order === undefined) return false; switch (block.type) { case 'heading': return ( typeof block.level === 'number' && block.level >= 1 && block.level <= 6 ); case 'paragraph': return true; case 'table': { const table = block as Partial; return !!( table.metadata && table.content && validateTableStructure(table as TableBlock) ); } case 'image': return !!(block.content && typeof block.content === 'string'); case 'toc': return true; // TOC块总是有效的 default: return false; } } // ══════════════════════════════════════════════════════════════════════════════ // Block Search and Filter // ══════════════════════════════════════════════════════════════════════════════ /** * 在blocks中搜索文本 * * @param blocks 块数组 * @param query 搜索关键词 * @returns 匹配的块ID数组 */ export function searchBlocks(blocks: DocumentBlock[], query: string): string[] { const lowerQuery = query.toLowerCase(); return blocks .filter((block) => { switch (block.type) { case 'heading': case 'paragraph': { const content = typeof block.content === 'string' ? block.content : block.content.map((seg) => seg.text).join(''); return content.toLowerCase().includes(lowerQuery); } case 'table': { return block.content.rows.some((row) => row.cells.some((cell) => { const text = typeof cell.text === 'string' ? cell.text : cell.text.map((seg) => seg.text).join(''); return text.toLowerCase().includes(lowerQuery); }) ); } case 'image': { return block.metadata.alt?.toLowerCase().includes(lowerQuery); } case 'toc': { // 搜索TOC标题 return block.content.title?.toLowerCase().includes(lowerQuery); } default: return false; } }) .map((block) => block.id); } /** * 获取指定类型的所有块 * * @param blocks 块数组 * @param type 块类型 * @returns 匹配类型的块数组 */ export function filterBlocksByType( blocks: DocumentBlock[], type: BlockType ): T[] { return blocks.filter((block) => block.type === type) as T[]; }