Files
edentext/tests/unit/table-style.test.ts
T

375 lines
16 KiB
TypeScript

// Table styles: region resolution (precedence, banding, shared grid lines) and the
// command that materializes them into cell attrs, incl. re-banding after a row insert.
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import Bold from '@tiptap/extension-bold';
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
import { TableCellBackground } from '../../src/lib/editor/extensions/tableCellBackground';
import { TableCellBorders } from '../../src/lib/editor/extensions/tableCellBorders';
import { TableStyle } from '../../src/lib/editor/extensions/tableStyle';
import { HEADER_SHADE, TableHeaderRow, isHeaderStyled } from '../../src/lib/editor/extensions/tableHeaderRow';
import {
DEFAULT_TABLE_LOOK, TABLE_REGIONS, builtinTableStyles, parseTableLook, previewCellCss,
previewTextCss, resolveTableCell, styleLook, tableLookAttr,
type TableLook, type TableRegion, type TableStyle as TableStyleDef,
} from '../../src/lib/styles/tableStyles';
type N = any;
const RULE = '1pt solid #000000';
const banded: TableStyleDef = {
name: 'Test Bands',
border: null,
innerBorder: 'none',
regions: {
headerRow: { fill: '#EEEEEE', text: { bold: true }, borders: { bottom: RULE } },
bandedRow: { fill: '#FAFAFA' },
firstColumn: { text: { italic: true } },
},
};
const styles = { [banded.name]: banded };
const ALL_ON: TableLook = {
headerRow: true, lastRow: true, firstColumn: true,
lastColumn: true, bandedRow: true, bandedColumn: true,
};
const at = (style: TableStyleDef, row: number, col: number, rows = 4, cols = 2, look = ALL_ON) =>
resolveTableCell(style, { row, col, rows, cols }, look);
describe('resolveTableCell', () => {
it('layers overlapping regions in Word precedence order', () => {
const corner = at(banded, 0, 0);
expect(corner.regions).toEqual(['firstColumn', 'headerRow']);
// Both contribute: the header's bold and the first column's italic.
expect(corner.text).toEqual({ italic: true, bold: true });
expect(corner.fill).toBe('#EEEEEE');
});
it('counts banding over body rows, so a header row does not shift the stripes', () => {
expect(at(banded, 1, 1).fill).toBe(null); // first body row
expect(at(banded, 2, 1).fill).toBe('#FAFAFA');
expect(at(banded, 3, 1).fill).toBe(null);
// Without a header region the stripes start one row higher.
const noHeader: TableStyleDef = { ...banded, regions: { bandedRow: { fill: '#FAFAFA' } } };
expect(at(noHeader, 0, 0).fill).toBe(null);
expect(at(noHeader, 1, 0).fill).toBe('#FAFAFA');
});
it('gives both cells of a grid line the same border', () => {
// The rule under the header row, seen from above and from below.
expect(at(banded, 0, 0).borders.borderBottom).toBe(RULE);
expect(at(banded, 1, 0).borders.borderTop).toBe(RULE);
// Outer edges take `border`, inner lines `innerBorder`.
expect(at(banded, 0, 0).borders.borderTop).toBe(null);
expect(at(banded, 0, 0).borders.borderRight).toBe('none');
expect(at(banded, 0, 1).borders.borderLeft).toBe('none');
expect(at(banded, 0, 1).borders.borderRight).toBe(null);
});
});
const cell = (text: string): N => ({
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
const row = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
function makeEditor(rows = 3) {
const el = document.createElement('div');
document.body.appendChild(el);
const content = Array.from({ length: rows }, (_, r) => row(cell(`a${r}`), cell(`b${r}`)));
return new Editor({
element: el,
extensions: [
Document, Paragraph, Text, Bold,
Table.configure({ resizable: false }), ResizableTableRow, TableHeader, TableCell,
TableCellBackground, TableCellBorders, TableHeaderRow,
TableStyle.configure({ styles: () => styles }),
],
content: { type: 'doc', content: [{ type: 'table', content }] },
});
}
const table = (editor: Editor): N => editor.getJSON().content![0];
const fills = (editor: Editor): (string | null)[] =>
table(editor).content.map((r: N) => r.content[0].attrs.backgroundColor ?? null);
describe('table style options (Word\'s tblLook)', () => {
it('only paints a region the table opts into', () => {
const off: TableLook = { ...ALL_ON, headerRow: false };
expect(at(banded, 0, 1).fill).toBe('#EEEEEE');
expect(at(banded, 0, 1, 4, 2, off).fill).toBe(null);
// The header's rule goes with it, from both sides of that grid line.
expect(at(banded, 0, 0, 4, 2, off).borders.borderBottom).toBe('none');
expect(at(banded, 1, 0, 4, 2, off).borders.borderTop).toBe('none');
});
it('shifts the banding when the header row is switched off', () => {
const off: TableLook = { ...ALL_ON, headerRow: false };
// Header on: row 0 is the header, so the first stripe is row 2.
expect([0, 1, 2, 3].map(r => at(banded, r, 1).fill)).toEqual([
'#EEEEEE', null, '#FAFAFA', null,
]);
// Header off: the body starts at row 0, so the stripes move up one row.
expect([0, 1, 2, 3].map(r => at(banded, r, 1, 4, 2, off).fill)).toEqual([
null, '#FAFAFA', null, '#FAFAFA',
]);
});
it('drops the banding entirely when switched off', () => {
const off: TableLook = { ...ALL_ON, bandedRow: false };
expect([1, 2, 3].map(r => at(banded, r, 1, 4, 2, off).fill)).toEqual([null, null, null]);
// The header still counts for the (now unused) body offset, and still paints.
expect(at(banded, 0, 1, 4, 2, off).fill).toBe('#EEEEEE');
});
it('round-trips the look attr, defaulting to Word\'s', () => {
expect(parseTableLook(null)).toEqual(DEFAULT_TABLE_LOOK);
expect(parseTableLook('')).toEqual({
headerRow: false, lastRow: false, firstColumn: false,
lastColumn: false, bandedRow: false, bandedColumn: false,
});
const look = parseTableLook('headerRow bandedRow');
expect(look.headerRow && look.bandedRow).toBe(true);
expect(look.firstColumn || look.lastRow).toBe(false);
expect(parseTableLook(tableLookAttr(ALL_ON))).toEqual(ALL_ON);
});
});
describe('built-in styles', () => {
it('has families that shade the emphasis areas, not only embolden them', () => {
const sheet = builtinTableStyles();
const shaded = Object.values(sheet).filter(
st => ['firstColumn', 'lastColumn', 'lastRow'].every(r => st.regions[r as TableRegion]?.fill),
);
// The colourful families (box lists + the two accent tables) fill those areas, so
// switching one on is as visible as the header row.
expect(shaded.map(st => st.name)).toContain('Box List Blue');
expect(shaded.length).toBeGreaterThanOrEqual(6);
// …and the plain/rule-based ones deliberately stay text-only.
expect(sheet['Academic'].regions.firstColumn?.fill).toBeUndefined();
expect(sheet['Academic'].regions.firstColumn?.text?.bold).toBe(true);
});
it('renders no two built-ins identically', () => {
// Each style is previewed the way it is applied: its own options over the default.
// Giving every style all six areas once made the two list styles indistinguishable.
const signature = (style: TableStyleDef) => {
const look = styleLook(style, DEFAULT_TABLE_LOOK);
const cells: string[] = [];
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 4; c++) {
cells.push(previewCellCss(style, r, c, 5, 4, look) + previewTextCss(style, r, c, 5, 4, look));
}
}
return cells.join('|');
};
const seen = new Map<string, string>();
for (const style of Object.values(builtinTableStyles())) {
const sig = signature(style);
expect(seen.get(sig), `${style.name} looks exactly like ${seen.get(sig)}`).toBeUndefined();
seen.set(sig, style.name);
}
});
it('gives every built-in all six areas, so no toggle is a no-op', () => {
for (const style of Object.values(builtinTableStyles())) {
for (const region of TABLE_REGIONS) {
expect(style.regions[region], `${style.name} / ${region}`).toBeDefined();
}
}
});
});
describe('preview tiles', () => {
it('shows a bold-only region, which the fills alone cannot', () => {
const bar = (r: number, c: number, look = ALL_ON) =>
previewTextCss(banded, r, c, 4, 2, look);
// The first column is emphasis-only: no fill, so the tile can only show it via
// the text line's weight.
const fill = (r: number, c: number) => /^background: [^;]*/.exec(previewCellCss(banded, r, c, 4, 2, ALL_ON))![0];
expect(fill(1, 0)).toBe(fill(1, 1)); // identical cell background
// The fixture's first column is italic-only, the header bold — both must show.
expect(bar(1, 0)).toContain('skewX'); // first column: italic
expect(bar(1, 1)).toContain('none'); // body: upright
expect(bar(0, 1)).toContain('height: 2px'); // header row: bold
expect(bar(1, 1)).toContain('height: 1px');
// Switching the area off levels them out again.
const off: TableLook = { ...ALL_ON, firstColumn: false };
expect(bar(1, 0, off)).toBe(bar(1, 1, off));
});
});
describe('header toggles and style options are one state', () => {
it('drives the look on a styled table, the shading otherwise', () => {
const editor = makeEditor(3);
editor.commands.focus('start');
// No style: the toolbar toggle paints the header fill, as before.
editor.commands.toggleHeaderRowStyle();
expect(isHeaderStyled(editor.state, 'row')).toBe(true);
expect(table(editor).content[0].content[0].attrs.backgroundColor).toBe(HEADER_SHADE);
editor.commands.toggleHeaderRowStyle();
expect(isHeaderStyled(editor.state, 'row')).toBe(false);
// With a style the same button flips the Table Style Option instead — so the
// toolbar button and the gallery checkbox can never disagree.
editor.commands.setTableStyle('Test Bands');
expect(isHeaderStyled(editor.state, 'row')).toBe(true);
editor.commands.toggleHeaderRowStyle();
expect(parseTableLook(table(editor).attrs.tableLook).headerRow).toBe(false);
expect(isHeaderStyled(editor.state, 'row')).toBe(false);
expect(table(editor).content[0].content[0].attrs.backgroundColor).toBe(null);
// And the other way round: setting the option updates the button's state.
editor.commands.setTableLook('headerRow', true);
expect(isHeaderStyled(editor.state, 'row')).toBe(true);
editor.destroy();
});
it('maps the header-column button onto the first-column option', () => {
const editor = makeEditor(3);
editor.commands.focus('start');
editor.commands.setTableStyle('Test Bands');
expect(isHeaderStyled(editor.state, 'column')).toBe(true);
editor.commands.toggleHeaderColumnStyle();
expect(parseTableLook(table(editor).attrs.tableLook).firstColumn).toBe(false);
expect(isHeaderStyled(editor.state, 'column')).toBe(false);
editor.destroy();
});
});
describe('setTableStyle', () => {
it('materializes fill, borders and regions, and is idempotent', () => {
const editor = makeEditor();
editor.commands.focus('start');
editor.commands.setTableStyle('Test Bands');
const t = table(editor);
expect(t.attrs.tableStyle).toBe('Test Bands');
expect(t.content[0].content[0].attrs.backgroundColor).toBe('#EEEEEE');
expect(t.content[0].content[0].attrs.region).toBe('firstColumn headerRow');
expect(t.content[0].content[1].attrs.region).toBe('headerRow');
expect(t.content[0].content[0].attrs.borderBottom).toBe(RULE);
expect(t.content[1].content[0].attrs.borderTop).toBe(RULE);
expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA']);
// Applying again changes nothing — the re-band plugin depends on this.
const before = JSON.stringify(editor.getJSON());
editor.commands.setTableStyle('Test Bands');
expect(JSON.stringify(editor.getJSON())).toBe(before);
editor.destroy();
});
it('re-bands when a row is inserted', () => {
const editor = makeEditor();
editor.commands.focus('start');
editor.commands.setTableStyle('Test Bands');
expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA']);
// Cursor is in the header row; a row added after it shifts every stripe below.
editor.commands.addRowAfter();
expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA', null]);
editor.destroy();
});
it('clears the style-owned attrs again', () => {
const editor = makeEditor();
editor.commands.focus('start');
editor.commands.setTableStyle('Test Bands');
editor.commands.setTableStyle(null);
const t = table(editor);
expect(t.attrs.tableStyle).toBe(null);
for (const r of t.content) {
for (const c of r.content) {
expect(c.attrs.backgroundColor).toBe(null);
expect(c.attrs.region).toBe(null);
expect(c.attrs.borderTop).toBe(null);
}
}
editor.destroy();
});
it('re-derives the regions of an imported table from the name alone', () => {
// An imported file carries the style name and the baked fill, but no region attrs
// (ODF/DOCX have no such concept) — refreshTableStyles restores them on load.
const el = document.createElement('div');
document.body.appendChild(el);
const editor = new Editor({
element: el,
extensions: [
Document, Paragraph, Text, Bold,
Table.configure({ resizable: false }), ResizableTableRow, TableHeader, TableCell,
TableCellBackground, TableCellBorders,
TableStyle.configure({ styles: () => styles }),
],
content: {
type: 'doc',
content: [{
type: 'table',
attrs: { tableStyle: 'Test Bands' },
content: [row(cell('H'), cell('H2')), row(cell('a'), cell('b'))],
}],
},
});
expect(table(editor).content[0].content[0].attrs.region).toBe(null);
editor.commands.refreshTableStyles();
expect(table(editor).content[0].content[0].attrs.region).toBe('firstColumn headerRow');
expect(table(editor).content[0].content[0].attrs.backgroundColor).toBe('#EEEEEE');
editor.destroy();
});
it('places a merged cell by its top-left grid position', () => {
const editor = makeEditor();
editor.commands.focus('start');
// Merge the two cells of the second row, then style: the merged cell is row 1.
const cells: number[] = [];
editor.state.doc.descendants((n, pos) => { if (n.type.name === 'tableCell') cells.push(pos); });
editor.commands.setCellSelection({ anchorCell: cells[2], headCell: cells[3] });
editor.commands.mergeCells();
editor.commands.setTableStyle('Test Bands');
const t = table(editor);
expect(t.content[1].content.length).toBe(1);
expect(t.content[1].content[0].attrs.colspan).toBe(2);
expect(t.content[1].content[0].attrs.region).toBe('firstColumn');
// Its bottom is the boundary to the banded row below, its right the table edge.
expect(t.content[1].content[0].attrs.borderRight).toBe(null);
editor.destroy();
});
it('repaints when an option is toggled', () => {
const editor = makeEditor(4);
editor.commands.focus('start');
editor.commands.setTableStyle('Test Bands');
expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA', null]);
// Header row off: it loses its fill and the stripes move up a row.
editor.commands.setTableLook('headerRow', false);
expect(fills(editor)).toEqual([null, '#FAFAFA', null, '#FAFAFA']);
expect(table(editor).content[0].content[0].attrs.region).toBe('firstColumn');
// And back again.
editor.commands.setTableLook('headerRow', true);
expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA', null]);
editor.destroy();
});
it('ignores an unknown style name', () => {
const editor = makeEditor();
editor.commands.focus('start');
expect(editor.commands.setTableStyle('Nope')).toBe(false);
expect(table(editor).attrs.tableStyle).toBe(null);
editor.destroy();
});
});