375 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|