Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const decode = (params) => {
const grid = Array.isArray(rawGrid) ? rawGrid : [];
const { firstRow = {}, preferTableGrid = false, totalColumns: requestedColumns } = params.extraParams || {};

const cellNodes = firstRow.content?.filter((n) => n.type === 'tableCell') ?? [];
const cellNodes = firstRow.content?.filter((n) => n.type === 'tableCell' || n.type === 'tableHeader') ?? [];

let colWidthsFromCellNodes = cellNodes.flatMap((cell) => {
const spanCount = Math.max(1, cell?.attrs?.colspan ?? 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,28 @@ describe('w:tblGrid translator', () => {
expect(widths).toEqual(['2000', '4000']);
});

it('derives grid widths from tableHeader cells in header-only first row', () => {
const params = {
node: {
attrs: {},
},
extraParams: {
firstRow: {
content: [
{ type: 'tableHeader', attrs: { colspan: 1, colwidth: [80] } },
{ type: 'tableHeader', attrs: { colspan: 1, colwidth: [120] } },
],
},
},
};

const result = translator.decode(params);
expect(result.name).toBe('w:tblGrid');
const widths = result.elements.map((el) => el.attributes['w:w']);
// 80 * 20 = 1600, 120 * 20 = 2400 (via mocked pixelsToTwips)
expect(widths).toEqual(['1600', '2400']);
});

it('preserves narrow grid columns for placeholder cells without inflating width', () => {
const params = {
node: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,28 @@ export function generateTableCellProperties(node) {
const { attrs } = node;

// Width
const { colwidth = [], cellWidthType = 'dxa', widthUnit } = attrs;
const colwidthSum = colwidth.reduce((acc, curr) => acc + curr, 0);
const propertiesWidthPixels = twipsToPixels(tableCellProperties.cellWidth?.value);
if (propertiesWidthPixels !== colwidthSum) {
// If the value has changed, update it
tableCellProperties['cellWidth'] = {
value: widthUnit === 'px' ? pixelsToTwips(colwidthSum) : inchesToTwips(colwidthSum),
type: cellWidthType,
};
const { colwidth: rawColwidth, widthUnit = 'px' } = attrs;
const resolvedWidthType =
attrs.cellWidthType ??
(attrs.widthType !== 'auto' ? attrs.widthType : undefined) ??
tableCellProperties.cellWidth?.type ??
'dxa';

// Filter to finite numbers to guard against NaN/Infinity/non-numeric entries
const colwidth = Array.isArray(rawColwidth) ? rawColwidth.filter((v) => Number.isFinite(v)) : [];

// Skip rewrite when:
// - colwidth is empty (no data to compute from — preserve original cellWidth)
// - resolvedWidthType is 'pct' (colwidth is in pixels but type expects fiftieths-of-percent)
if (colwidth.length > 0 && resolvedWidthType !== 'pct') {
const colwidthSum = colwidth.reduce((acc, curr) => acc + curr, 0);
const propertiesWidthPixels = twipsToPixels(tableCellProperties.cellWidth?.value);
if (propertiesWidthPixels !== colwidthSum) {
tableCellProperties['cellWidth'] = {
value: widthUnit === 'px' ? pixelsToTwips(colwidthSum) : inchesToTwips(colwidthSum),
type: resolvedWidthType,
};
}
}

// Colspan
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,118 @@ describe('translate-table-cell helpers', () => {
expect(out.elements[1]).toMatchObject({ name: 'w:p' });
});
});

/** Helper: extract w:tcW element from a generateTableCellProperties result */
function getTcW(tcPr) {
return tcPr.elements?.find((e) => e.name === 'w:tcW') ?? null;
}

describe('IT-550: tableHeader width export fixes', () => {
it('uses pixelsToTwips when widthUnit is px', () => {
const node = { attrs: { colwidth: [100], widthUnit: 'px' } };
const tcPr = generateTableCellProperties(node);
const tcW = getTcW(tcPr);
expect(tcW.attributes['w:w']).toBe(String(pixelsToTwips(100)));
expect(tcW.attributes['w:type']).toBe('dxa');
});

it('defaults widthUnit to px when missing', () => {
// Simulates a tableHeader node before Step 1 fix — no widthUnit attr at all
const node = { attrs: { colwidth: [100] } };
const tcPr = generateTableCellProperties(node);
const tcW = getTcW(tcPr);
// Should use pixelsToTwips (not inchesToTwips), producing 1500, not 144000
expect(tcW.attributes['w:w']).toBe(String(pixelsToTwips(100)));
});

it('preserves existing cellWidth when colwidth is null', () => {
const originalCellWidth = { value: 3000, type: 'dxa' };
const node = {
attrs: {
colwidth: null,
widthUnit: 'px',
tableCellProperties: { cellWidth: originalCellWidth },
},
};
const tcPr = generateTableCellProperties(node);
const tcW = getTcW(tcPr);
// Should preserve the original value, not write 0
expect(tcW.attributes['w:w']).toBe('3000');
expect(tcW.attributes['w:type']).toBe('dxa');
});

it('preserves existing cellWidth when colwidth is empty array', () => {
const originalCellWidth = { value: 3000, type: 'dxa' };
const node = {
attrs: {
colwidth: [],
widthUnit: 'px',
tableCellProperties: { cellWidth: originalCellWidth },
},
};
const tcPr = generateTableCellProperties(node);
const tcW = getTcW(tcPr);
expect(tcW.attributes['w:w']).toBe('3000');
});

it('filters non-finite values from colwidth', () => {
const node = { attrs: { colwidth: [100, NaN, 50], widthUnit: 'px' } };
const tcPr = generateTableCellProperties(node);
const tcW = getTcW(tcPr);
// Only 100 + 50 = 150 should be summed (NaN filtered out)
expect(tcW.attributes['w:w']).toBe(String(pixelsToTwips(150)));
});

it('preserves original cellWidth for pct width type', () => {
// Simulates a pct-imported cell: widthType is 'pct', colwidth is in pixels (from tblGrid fallback)
const originalCellWidth = { value: 5000, type: 'pct' };
const node = {
attrs: {
colwidth: [200],
widthUnit: 'px',
widthType: 'pct',
tableCellProperties: { cellWidth: originalCellWidth },
},
};
const tcPr = generateTableCellProperties(node);
const tcW = getTcW(tcPr);
// Should preserve original pct value, NOT rewrite with pixelsToTwips(200)
expect(tcW.attributes['w:w']).toBe('5000');
expect(tcW.attributes['w:type']).toBe('pct');
});

it('resolves widthType dxa from node attrs', () => {
const node = { attrs: { colwidth: [100], widthUnit: 'px', widthType: 'dxa' } };
const tcPr = generateTableCellProperties(node);
const tcW = getTcW(tcPr);
expect(tcW.attributes['w:type']).toBe('dxa');
});

it('falls through auto widthType to tableCellProperties.cellWidth.type', () => {
const node = {
attrs: {
colwidth: [100],
widthUnit: 'px',
widthType: 'auto',
tableCellProperties: { cellWidth: { value: 1500, type: 'dxa' } },
},
};
const tcPr = generateTableCellProperties(node);
const tcW = getTcW(tcPr);
// 'auto' is the uninformative default — should fall through to tableCellProperties type
expect(tcW.attributes['w:type']).toBe('dxa');
});

it('falls through auto widthType to dxa when no tableCellProperties type', () => {
const node = {
attrs: {
colwidth: [100],
widthUnit: 'px',
widthType: 'auto',
},
};
const tcPr = generateTableCellProperties(node);
const tcW = getTcW(tcPr);
expect(tcW.attributes['w:type']).toBe('dxa');
});
});
61 changes: 59 additions & 2 deletions packages/super-editor/src/extensions/table-header/table-header.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ import { renderCellBorderStyle } from '../table-cell/helpers/renderCellBorderSty
* @category Attributes
* @property {number} [colspan=1] - Number of columns this header spans
* @property {number} [rowspan=1] - Number of rows this header spans
* @property {number[]} [colwidth] - Column widths array in pixels
* @property {number[]} [colwidth=[100]] - Column widths array in pixels
* @property {import('../table-cell/table-cell.js').CellBackground} [background] - Cell background color configuration
* @property {string} [verticalAlign] - Vertical content alignment (top, middle, bottom)
* @property {import('../table-cell/table-cell.js').CellMargins} [cellMargins] - Internal cell padding
* @property {import('../table-cell/helpers/createCellBorders.js').CellBorders} [borders] - Cell border configuration
* @property {string} [widthType='auto'] @internal - Internal width type
* @property {string} [widthUnit='px'] @internal - Internal width unit
* @property {import('../table-cell/table-cell.js').TableCellProperties} [tableCellProperties] @internal - Raw OOXML cell properties
*/

/**
Expand Down Expand Up @@ -54,7 +60,7 @@ export const TableHeader = Node.create({
},

colwidth: {
default: null,
default: [100],
parseDOM: (element) => {
const colwidth = element.getAttribute('data-colwidth');
const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null;
Expand All @@ -69,11 +75,62 @@ export const TableHeader = Node.create({
},
},

background: {
renderDOM({ background }) {
if (!background) return {};
// @ts-expect-error - background is known to be an object at runtime
const { color } = background || {};
const style = `background-color: ${color ? `#${color}` : 'transparent'}`;
return { style };
},
},

verticalAlign: {
renderDOM({ verticalAlign }) {
if (!verticalAlign) return {};
const style = `vertical-align: ${verticalAlign}`;
return { style };
},
},

cellMargins: {
renderDOM({ cellMargins, borders }) {
if (!cellMargins) return {};
const sides = ['top', 'right', 'bottom', 'left'];
const style = sides
.map((side) => {
const margin = cellMargins?.[side] ?? 0;
const border = borders?.[side];
const borderSize = border && border.val !== 'none' ? Math.ceil(border.size) : 0;

if (margin) return `padding-${side}: ${Math.max(0, margin - borderSize)}px;`;
return '';
})
.join(' ');
return { style };
},
},

borders: {
default: () => createCellBorders(),
renderDOM: ({ borders }) => renderCellBorderStyle(borders),
},

widthType: {
default: 'auto',
rendered: false,
},

widthUnit: {
default: 'px',
rendered: false,
},

tableCellProperties: {
default: null,
rendered: false,
},

__placeholder: {
default: null,
parseDOM: (element) => {
Expand Down
65 changes: 65 additions & 0 deletions packages/super-editor/src/extensions/table/table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,71 @@ describe('Table commands', async () => {
});
});

describe('toggleHeaderRow preserves cell attributes (IT-550)', async () => {
beforeEach(async () => {
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));
({ schema } = editor);

// Create a 2x2 table with explicit cell attributes
const CellType = schema.nodes.tableCell;
const RowType = schema.nodes.tableRow;
const TableType = schema.nodes.table;

const cellAttrs = {
colspan: 1,
rowspan: 1,
colwidth: [150],
widthUnit: 'px',
widthType: 'dxa',
background: { color: 'FF0000' },
tableCellProperties: { cellWidth: { value: 2250, type: 'dxa' } },
};

const makeCell = (text) => CellType.create(cellAttrs, schema.nodes.paragraph.create(null, schema.text(text)));

const row1 = RowType.create(null, [makeCell('A'), makeCell('B')]);
const row2 = RowType.create(null, [makeCell('C'), makeCell('D')]);
table = TableType.create(null, [row1, row2]);

const doc = schema.nodes.doc.create(null, [table]);
const nextState = EditorState.create({ schema, doc, plugins: editor.state.plugins });
editor.setState(nextState);
});

it('toggleHeaderRow preserves widthUnit, widthType, background, and tableCellProperties', async () => {
const tablePos = findTablePos(editor.state.doc);
expect(tablePos).not.toBeNull();

// Position cursor in first row
editor.commands.setTextSelection(tablePos + 3);

// Toggle first row to header
const didToggle = editor.commands.toggleHeaderRow();
expect(didToggle).toBe(true);

const updatedTable = editor.state.doc.nodeAt(tablePos);
const firstRow = updatedTable.child(0);

// First row cells should now be tableHeader type
firstRow.forEach((cell) => {
expect(cell.type.name).toBe('tableHeader');
// Critical attrs that were previously dropped
expect(cell.attrs.widthUnit).toBe('px');
expect(cell.attrs.widthType).toBe('dxa');
expect(cell.attrs.colwidth).toEqual([150]);
expect(cell.attrs.background).toEqual({ color: 'FF0000' });
expect(cell.attrs.tableCellProperties).toEqual({ cellWidth: { value: 2250, type: 'dxa' } });
});

// Second row should remain tableCell
const secondRow = updatedTable.child(1);
secondRow.forEach((cell) => {
expect(cell.type.name).toBe('tableCell');
});
});
});

describe('deleteCellAndTableBorders', async () => {
let table, tablePos;

Expand Down
Loading
Loading