All files / roosterjs-content-model-dom/lib/modelApi/editing normalizeTable.ts

96.05% Statements 73/76
90.16% Branches 55/61
100% Functions 14/14
95.45% Lines 63/66

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 1661x 1x 1x 1x 1x                     1x       1x                         1x       19x     19x   19x 17x 17x           19x 39x 97x   97x 32x           32x       32x     97x 40x 57x 1x     97x 39x     97x       39x 17x       39x   19x 40x 19x 21x 3x           19x   22x 22x 58x 58x 58x 5x       24x 4x 3x               21x 21x   21x 56x 56x 3x       22x 2x 2x           19x 19x                       8x 8x     8x 7x 7x         8x   8x    
import { addBlock } from '../common/addBlock';
import { addSegment } from '../common/addSegment';
import { createBr } from '../creators/createBr';
import { createParagraph } from '../creators/createParagraph';
import { mutateBlock } from '../common/mutate';
import type {
    ContentModelSegmentFormat,
    ReadonlyContentModelSegment,
    ReadonlyContentModelTable,
    ReadonlyContentModelTableCell,
} from 'roosterjs-content-model-types';
 
/**
 * Minimum width for a table cell
 */
export const MIN_ALLOWED_TABLE_CELL_WIDTH: number = 30;
/**
 * Minimum height for a table cell
 */
export const MIN_ALLOWED_TABLE_CELL_HEIGHT: number = 22;
 
/**
 * Normalize a Content Model table, make sure:
 * 1. Fist cells are not spanned
 * 2. Only first column and row can have headers
 * 3. All cells have content and width
 * 4. Table and table row have correct width/height
 * 5. Spanned cell has no child blocks
 * 6. default format is correctly applied
 * @param readonlyTable The table to normalize
 * @param defaultSegmentFormat @optional Default segment format to apply to cell
 */
export function normalizeTable(
    readonlyTable: ReadonlyContentModelTable,
    defaultSegmentFormat?: ContentModelSegmentFormat
) {
    const table = mutateBlock(readonlyTable);
 
    // Always collapse border and use border box for table in roosterjs to make layout simpler
    const format = table.format;
 
    if (!format.borderCollapse || !format.useBorderBox) {
        format.borderCollapse = true;
        format.useBorderBox = true;
    }
 
    // Make sure all first cells are not spanned
    // Make sure all inner cells are not header
    // Make sure all cells have content and width
    table.rows.forEach((row, rowIndex) => {
        row.cells.forEach((readonlyCell, colIndex) => {
            const cell = mutateBlock(readonlyCell);
 
            if (cell.blocks.length == 0) {
                const format = cell.format.textColor
                    ? {
                          ...defaultSegmentFormat,
                          textColor: cell.format.textColor,
                      }
                    : defaultSegmentFormat;
                addBlock(
                    cell,
                    createParagraph(undefined /*isImplicit*/, undefined /*blockFormat*/, format)
                );
                addSegment(cell, createBr(format));
            }
 
            if (rowIndex == 0) {
                cell.spanAbove = false;
            } else if (rowIndex > 0 && colIndex > 0 && cell.isHeader) {
                cell.isHeader = false;
            }
 
            if (colIndex == 0) {
                cell.spanLeft = false;
            }
 
            cell.format.useBorderBox = true;
        });
 
        // Make sure table has correct width and height array
        if (row.height < MIN_ALLOWED_TABLE_CELL_HEIGHT) {
            row.height = MIN_ALLOWED_TABLE_CELL_HEIGHT;
        }
    });
 
    const columns = Math.max(...table.rows.map(row => row.cells.length));
 
    for (let i = 0; i < columns; i++) {
        if (table.widths[i] === undefined) {
            table.widths[i] = getTableCellWidth(columns);
        } else if (table.widths[i] < MIN_ALLOWED_TABLE_CELL_WIDTH) {
            table.widths[i] = MIN_ALLOWED_TABLE_CELL_WIDTH;
        }
    }
 
    // Move blocks from spanned cell to its main cell if any,
    // and remove rows/columns if all cells in it are spanned
    const colCount = table.rows[0]?.cells.length || 0;
 
    for (let colIndex = colCount - 1; colIndex > 0; colIndex--) {
        table.rows.forEach(row => {
            const cell = row.cells[colIndex];
            const leftCell = row.cells[colIndex - 1];
            if (cell && leftCell && cell.spanLeft) {
                tryMoveBlocks(leftCell, cell);
            }
        });
 
        if (table.rows.every(row => row.cells[colIndex]?.spanLeft)) {
            table.rows.forEach(row => row.cells.splice(colIndex, 1));
            table.widths.splice(
                colIndex - 1,
                2,
                table.widths[colIndex - 1] + table.widths[colIndex]
            );
        }
    }
 
    for (let rowIndex = table.rows.length - 1; rowIndex > 0; rowIndex--) {
        const row = table.rows[rowIndex];
 
        row.cells.forEach((cell, colIndex) => {
            const aboveCell = table.rows[rowIndex - 1]?.cells[colIndex];
            if (aboveCell && cell.spanAbove) {
                tryMoveBlocks(aboveCell, cell);
            }
        });
 
        if (row.cells.every(cell => cell.spanAbove)) {
            table.rows[rowIndex - 1].height += row.height;
            table.rows.splice(rowIndex, 1);
        }
    }
}
 
function getTableCellWidth(columns: number): number {
    Eif (columns <= 4) {
        return 120;
    } else if (columns <= 6) {
        return 100;
    } else {
        return 70;
    }
}
 
function tryMoveBlocks(
    targetCell: ReadonlyContentModelTableCell,
    sourceCell: ReadonlyContentModelTableCell
) {
    const onlyHasEmptyOrBr = sourceCell.blocks.every(
        block => block.blockType == 'Paragraph' && hasOnlyBrSegment(block.segments)
    );
 
    if (!onlyHasEmptyOrBr) {
        mutateBlock(targetCell).blocks.push(...sourceCell.blocks);
        mutateBlock(sourceCell).blocks = [];
    }
}
 
function hasOnlyBrSegment(segments: ReadonlyArray<ReadonlyContentModelSegment>): boolean {
    segments = segments.filter(s => s.segmentType != 'SelectionMarker');
 
    return segments.length == 0 || (segments.length == 1 && segments[0].segmentType == 'Br');
}