blockOperations.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. /**
  2. * blockOperations.ts - Block操作工具函数
  3. *
  4. * 提供block的各种操作辅助函数
  5. *
  6. * @module utils/blockOperations
  7. */
  8. import type {
  9. DocumentBlock,
  10. BlockType,
  11. TableBlock,
  12. TableRow,
  13. TableCell,
  14. RichText,
  15. } from '../types/editor';
  16. // ══════════════════════════════════════════════════════════════════════════════
  17. // Block ID Generation
  18. // ══════════════════════════════════════════════════════════════════════════════
  19. /**
  20. * 生成块ID
  21. *
  22. * @param type 块类型
  23. * @param index 索引(可选)
  24. * @returns 块ID
  25. *
  26. * @example
  27. * ```ts
  28. * generateBlockId('heading') // "block-h-1704096000000-0"
  29. * ```
  30. */
  31. export function generateBlockId(type: BlockType, index: number = 0): string {
  32. const typePrefix: Record<BlockType, string> = {
  33. heading: 'h',
  34. paragraph: 'p',
  35. table: 'tbl',
  36. image: 'img',
  37. };
  38. return `block-${typePrefix[type]}-${Date.now()}-${index}`;
  39. }
  40. // ══════════════════════════════════════════════════════════════════════════════
  41. // Block Order Operations
  42. // ══════════════════════════════════════════════════════════════════════════════
  43. /**
  44. * 计算插入位置的block_order
  45. * 稀疏排序策略:在两个块之间找到中间值
  46. *
  47. * @param prevOrder 前一个块的order
  48. * @param nextOrder 后一个块的order
  49. * @returns 新的order,如果返回-1表示需要重排
  50. *
  51. * @example
  52. * ```ts
  53. * computeInsertOrder(100, 200) // 150
  54. * computeInsertOrder(100, 101) // -1 (需要重排)
  55. * ```
  56. */
  57. export function computeInsertOrder(prevOrder: number, nextOrder: number): number {
  58. const gap = nextOrder - prevOrder;
  59. if (gap > 1) {
  60. // 有间隙,直接取中间值
  61. return Math.floor((prevOrder + nextOrder) / 2);
  62. }
  63. // 间隙不足,需要重排
  64. return -1;
  65. }
  66. /**
  67. * 重新平衡block_order(稀疏排序,间隔100)
  68. *
  69. * @param blocks 块数组
  70. * @returns 重新排序后的块数组
  71. *
  72. * @example
  73. * ```ts
  74. * const rebalanced = rebalanceBlockOrders(blocks);
  75. * // blocks[0].block_order = 0
  76. * // blocks[1].block_order = 100
  77. * // blocks[2].block_order = 200
  78. * ```
  79. */
  80. export function rebalanceBlockOrders(blocks: DocumentBlock[]): DocumentBlock[] {
  81. const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
  82. return sorted.map((block, index) => ({
  83. ...block,
  84. block_order: index * 100,
  85. }));
  86. }
  87. // ══════════════════════════════════════════════════════════════════════════════
  88. // Table Operations
  89. // ══════════════════════════════════════════════════════════════════════════════
  90. /**
  91. * 创建空的表格单元格
  92. *
  93. * @returns 空单元格
  94. */
  95. export function createEmptyCell(): TableCell {
  96. return {
  97. text: '',
  98. rowspan: 1,
  99. colspan: 1,
  100. style: {},
  101. };
  102. }
  103. /**
  104. * 创建空的表格行
  105. *
  106. * @param cols 列数
  107. * @returns 表格行
  108. */
  109. export function createEmptyRow(cols: number): TableRow {
  110. return {
  111. cells: Array(cols).fill(null).map(() => createEmptyCell()),
  112. };
  113. }
  114. /**
  115. * 在表格中插入行
  116. *
  117. * @param table 表格块
  118. * @param afterRow 在此行之后插入
  119. * @returns 新的表格块
  120. *
  121. * @example
  122. * ```ts
  123. * const newTable = insertTableRow(table, 1); // 在第2行后插入
  124. * ```
  125. */
  126. export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
  127. const newRow = createEmptyRow(table.metadata.cols);
  128. const rows = [...table.content.rows];
  129. rows.splice(afterRow + 1, 0, newRow);
  130. return {
  131. ...table,
  132. content: { rows },
  133. metadata: {
  134. ...table.metadata,
  135. rows: rows.length,
  136. },
  137. };
  138. }
  139. /**
  140. * 在表格中插入列
  141. *
  142. * @param table 表格块
  143. * @param afterCol 在此列之后插入
  144. * @returns 新的表格块
  145. *
  146. * @example
  147. * ```ts
  148. * const newTable = insertTableColumn(table, 1); // 在第2列后插入
  149. * ```
  150. */
  151. export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
  152. const rows = table.content.rows.map((row) => {
  153. const cells = [...row.cells];
  154. cells.splice(afterCol + 1, 0, createEmptyCell());
  155. return { cells };
  156. });
  157. const colWidths = [...table.metadata.col_widths];
  158. const avgWidth = colWidths.reduce((sum, w) => sum + w, 0) / colWidths.length;
  159. colWidths.splice(afterCol + 1, 0, avgWidth);
  160. return {
  161. ...table,
  162. content: { rows },
  163. metadata: {
  164. ...table.metadata,
  165. cols: table.metadata.cols + 1,
  166. col_widths: colWidths,
  167. },
  168. };
  169. }
  170. /**
  171. * 删除表格行
  172. *
  173. * @param table 表格块
  174. * @param rowIndex 行索引
  175. * @returns 新的表格块
  176. */
  177. export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock {
  178. if (table.content.rows.length <= 1) {
  179. throw new Error('表格至少需要一行');
  180. }
  181. const rows = table.content.rows.filter((_, i) => i !== rowIndex);
  182. return {
  183. ...table,
  184. content: { rows },
  185. metadata: {
  186. ...table.metadata,
  187. rows: rows.length,
  188. },
  189. };
  190. }
  191. /**
  192. * 删除表格列
  193. *
  194. * @param table 表格块
  195. * @param colIndex 列索引
  196. * @returns 新的表格块
  197. */
  198. export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlock {
  199. if (table.metadata.cols <= 1) {
  200. throw new Error('表格至少需要一列');
  201. }
  202. const rows = table.content.rows.map((row) => ({
  203. cells: row.cells.filter((_, i) => i !== colIndex),
  204. }));
  205. const colWidths = table.metadata.col_widths.filter((_, i) => i !== colIndex);
  206. return {
  207. ...table,
  208. content: { rows },
  209. metadata: {
  210. ...table.metadata,
  211. cols: table.metadata.cols - 1,
  212. col_widths: colWidths,
  213. },
  214. };
  215. }
  216. /**
  217. * 合并单元格
  218. *
  219. * 支持横向合并(colspan)和纵向合并(rowspan)
  220. * 合并时会保存所有被合并单元格的原始内容和样式,以便拆分时恢复
  221. *
  222. * @param table 表格块
  223. * @param startRow 起始行
  224. * @param startCol 起始列
  225. * @param endRow 结束行
  226. * @param endCol 结束列
  227. * @returns 新的表格块
  228. *
  229. * @example
  230. * ```ts
  231. * // 横向合并: a + b (第0行,第0-1列)
  232. * mergeCells(table, 0, 0, 0, 1);
  233. * // 结果: a单元格 colspan=2, 保存b的内容和样式到 _mergedCells
  234. *
  235. * // 纵向合并: a + d (第0-1行,第0列)
  236. * mergeCells(table, 0, 0, 1, 0);
  237. * // 结果: a单元格 rowspan=2, 保存d的内容和样式到 _mergedCells
  238. * ```
  239. */
  240. export function mergeCells(
  241. table: TableBlock,
  242. startRow: number,
  243. startCol: number,
  244. endRow: number,
  245. endCol: number
  246. ): TableBlock {
  247. // 收集所有被合并单元格的内容和样式(保存位置偏移量)
  248. const mergedCells: Array<{
  249. rowOffset: number;
  250. colOffset: number;
  251. text: string | RichText[];
  252. style: any; // 保存原始样式
  253. }> = [];
  254. // 收集主单元格内容用于显示
  255. const displayTexts: string[] = [];
  256. table.content.rows.forEach((row, rowIdx) => {
  257. if (rowIdx >= startRow && rowIdx <= endRow) {
  258. row.cells.forEach((cell, colIdx) => {
  259. if (colIdx >= startCol && colIdx <= endCol) {
  260. const text = typeof cell.text === 'string'
  261. ? cell.text
  262. : cell.text.map(seg => seg.text).join('');
  263. // 保存所有单元格的原始数据和样式(包括主单元格)
  264. mergedCells.push({
  265. rowOffset: rowIdx - startRow,
  266. colOffset: colIdx - startCol,
  267. text: cell.text,
  268. style: { ...cell.style }, // 深拷贝样式
  269. });
  270. // 用于显示的文本
  271. if (text.trim()) {
  272. displayTexts.push(text.trim());
  273. }
  274. }
  275. });
  276. }
  277. });
  278. const rows = table.content.rows.map((row, rowIdx) => {
  279. if (rowIdx < startRow || rowIdx > endRow) {
  280. return row;
  281. }
  282. const cells = row.cells.map((cell, colIdx) => {
  283. if (colIdx < startCol || colIdx > endCol) {
  284. return cell;
  285. }
  286. if (rowIdx === startRow && colIdx === startCol) {
  287. // 主单元格,设置rowspan和colspan,保存原始数据
  288. return {
  289. ...cell,
  290. text: displayTexts.join(' ') || cell.text,
  291. rowspan: endRow - startRow + 1,
  292. colspan: endCol - startCol + 1,
  293. style: {
  294. ...cell.style,
  295. _mergedCells: mergedCells, // 保存所有单元格的原始内容和样式
  296. },
  297. };
  298. }
  299. // 被合并的单元格,标记为隐藏
  300. return {
  301. ...cell,
  302. text: '', // 清空显示内容
  303. rowspan: 0,
  304. colspan: 0,
  305. };
  306. });
  307. return { cells };
  308. });
  309. return {
  310. ...table,
  311. content: { rows },
  312. };
  313. }
  314. /**
  315. * 拆分单元格
  316. *
  317. * 将已合并的单元格拆分回独立单元格,并恢复原始内容和样式到对应位置
  318. *
  319. * @param table 表格块
  320. * @param rowIndex 单元格所在行
  321. * @param colIndex 单元格所在列
  322. * @returns 新的表格块
  323. *
  324. * @example
  325. * ```ts
  326. * // 拆分横向合并的单元格 (a+b)
  327. * splitCell(table, 0, 0);
  328. * // 结果: a单元格恢复为普通单元格(内容为原始a,样式为原始a样式), b单元格恢复(内容为原始b,样式为原始b样式)
  329. *
  330. * // 拆分纵向合并的单元格 (a+d)
  331. * splitCell(table, 0, 0);
  332. * // 结果: a单元格恢复为普通单元格(内容为原始a,样式为原始a样式), d单元格恢复(内容为原始d,样式为原始d样式)
  333. * ```
  334. */
  335. export function splitCell(
  336. table: TableBlock,
  337. rowIndex: number,
  338. colIndex: number
  339. ): TableBlock {
  340. const targetCell = table.content.rows[rowIndex]?.cells[colIndex];
  341. if (!targetCell) {
  342. throw new Error('单元格不存在');
  343. }
  344. // 如果单元格没有合并,无需拆分
  345. if (targetCell.rowspan <= 1 && targetCell.colspan <= 1) {
  346. throw new Error('此单元格未合并,无需拆分');
  347. }
  348. const rowspan = targetCell.rowspan || 1;
  349. const colspan = targetCell.colspan || 1;
  350. // 获取保存的原始单元格数据(包含text和style)
  351. const mergedCells = targetCell.style._mergedCells || [];
  352. // 创建一个映射,用于快速查找原始内容和样式
  353. const cellDataMap = new Map<string, { text: string | RichText[]; style: any }>();
  354. mergedCells.forEach(({ rowOffset, colOffset, text, style }) => {
  355. const key = `${rowOffset}-${colOffset}`;
  356. cellDataMap.set(key, { text, style: style || {} });
  357. });
  358. const rows = table.content.rows.map((row, rowIdx) => {
  359. // 不在合并范围内的行直接返回
  360. if (rowIdx < rowIndex || rowIdx >= rowIndex + rowspan) {
  361. return row;
  362. }
  363. const cells = row.cells.map((cell, colIdx) => {
  364. // 不在合并范围内的列直接返回
  365. if (colIdx < colIndex || colIdx >= colIndex + colspan) {
  366. return cell;
  367. }
  368. // 计算当前单元格在合并区域中的偏移量
  369. const rowOffset = rowIdx - rowIndex;
  370. const colOffset = colIdx - colIndex;
  371. const key = `${rowOffset}-${colOffset}`;
  372. // 从保存的数据中恢复原始内容和样式
  373. const cellData = cellDataMap.get(key);
  374. const originalText = cellData?.text || '';
  375. const originalStyle = cellData?.style || {};
  376. // 主单元格:恢复为普通单元格,清除合并标记
  377. if (rowIdx === rowIndex && colIdx === colIndex) {
  378. const newStyle = { ...originalStyle };
  379. delete newStyle._mergedCells; // 清除保存的合并数据
  380. return {
  381. ...cell,
  382. text: originalText,
  383. rowspan: 1,
  384. colspan: 1,
  385. style: newStyle, // 使用原始样式
  386. };
  387. }
  388. // 被合并的单元格:恢复为独立单元格,并恢复原始内容和样式
  389. const recoveredStyle = { ...originalStyle };
  390. delete recoveredStyle._mergedCells;
  391. return {
  392. ...createEmptyCell(),
  393. text: originalText,
  394. style: recoveredStyle, // 使用原始样式
  395. };
  396. });
  397. return { cells };
  398. });
  399. return {
  400. ...table,
  401. content: { rows },
  402. };
  403. }
  404. // ══════════════════════════════════════════════════════════════════════════════
  405. // Block Validation
  406. // ══════════════════════════════════════════════════════════════════════════════
  407. /**
  408. * 验证块数据是否有效
  409. *
  410. * @param block 块数据
  411. * @returns 是否有效
  412. */
  413. export function validateBlock(block: Partial<DocumentBlock>): boolean {
  414. if (!block.type) return false;
  415. if (block.block_order === undefined) return false;
  416. switch (block.type) {
  417. case 'heading':
  418. return (
  419. typeof block.level === 'number' &&
  420. block.level >= 1 &&
  421. block.level <= 6
  422. );
  423. case 'paragraph':
  424. return true;
  425. case 'table':
  426. return !!(
  427. block.metadata?.cols &&
  428. block.metadata?.rows &&
  429. (block as any).content?.rows
  430. );
  431. case 'image':
  432. return !!(block.content && typeof block.content === 'string');
  433. default:
  434. return false;
  435. }
  436. }
  437. // ══════════════════════════════════════════════════════════════════════════════
  438. // Block Search and Filter
  439. // ══════════════════════════════════════════════════════════════════════════════
  440. /**
  441. * 在blocks中搜索文本
  442. *
  443. * @param blocks 块数组
  444. * @param query 搜索关键词
  445. * @returns 匹配的块ID数组
  446. */
  447. export function searchBlocks(blocks: DocumentBlock[], query: string): string[] {
  448. const lowerQuery = query.toLowerCase();
  449. return blocks
  450. .filter((block) => {
  451. switch (block.type) {
  452. case 'heading':
  453. case 'paragraph': {
  454. const content =
  455. typeof block.content === 'string'
  456. ? block.content
  457. : block.content.map((seg) => seg.text).join('');
  458. return content.toLowerCase().includes(lowerQuery);
  459. }
  460. case 'table': {
  461. return block.content.rows.some((row) =>
  462. row.cells.some((cell) => {
  463. const text =
  464. typeof cell.text === 'string'
  465. ? cell.text
  466. : cell.text.map((seg) => seg.text).join('');
  467. return text.toLowerCase().includes(lowerQuery);
  468. })
  469. );
  470. }
  471. case 'image': {
  472. return block.metadata.alt?.toLowerCase().includes(lowerQuery);
  473. }
  474. default:
  475. return false;
  476. }
  477. })
  478. .map((block) => block.id);
  479. }
  480. /**
  481. * 获取指定类型的所有块
  482. *
  483. * @param blocks 块数组
  484. * @param type 块类型
  485. * @returns 匹配类型的块数组
  486. */
  487. export function filterBlocksByType<T extends DocumentBlock>(
  488. blocks: DocumentBlock[],
  489. type: BlockType
  490. ): T[] {
  491. return blocks.filter((block) => block.type === type) as T[];
  492. }