Skip to content

Commit f544aa0

Browse files
fix(rich-text-web): keep table sizes out of inline styles in class style mode
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 647fd33 commit f544aa0

6 files changed

Lines changed: 236 additions & 33 deletions

File tree

‎packages/pluggableWidgets/rich-text-web/CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1010

1111
- We fixed an issue where table and cell border settings had no effect when the style data format was set to "Class".
1212

13+
- We fixed an issue where table, column and cell sizes were saved as inline styles when the style data format was set to "Class".
14+
1315
## [5.1.0] - 2026-09-29
1416

1517
### Added

‎packages/pluggableWidgets/rich-text-web/src/__tests__/RichTextFormatStyle.spec.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,26 @@ describe("RichTextFormatStyle.scss table borders", () => {
3030
expect(css).not.toMatch(/border-(width|style|color): [^;]*\bor\b/);
3131
});
3232
});
33+
34+
// Class style mode saves table, column and cell sizes as data attributes only (no `style`
35+
// attribute), so the sizes must come from these rules. The attribute selectors keep them
36+
// off inline-mode tables.
37+
describe("RichTextFormatStyle.scss table sizes", () => {
38+
const css = compile(join(__dirname, "../ui/RichTextFormatStyle.scss")).css;
39+
40+
it.each([
41+
[".widget-rich-text table[data-width] {", "width: attr(data-width type(<length-percentage>));"],
42+
[".widget-rich-text table[data-min-height] {", "min-height: attr(data-min-height type(<length-percentage>));"],
43+
[".widget-rich-text col[data-col-width] {", "width: attr(data-col-width type(<length-percentage>));"],
44+
[
45+
".widget-rich-text td[data-cell-width],\n.widget-rich-text th[data-cell-width] {",
46+
"width: attr(data-cell-width type(<length-percentage>));"
47+
],
48+
[
49+
".widget-rich-text td[data-cell-height],\n.widget-rich-text th[data-cell-height] {",
50+
"height: attr(data-cell-height type(<length-percentage>));"
51+
]
52+
])("renders %s from a typed attr()", (selector, declaration) => {
53+
expect(css).toContain(`${selector}\n ${declaration}\n}`);
54+
});
55+
});

‎packages/pluggableWidgets/rich-text-web/src/extensions/TableBackgroundColor.ts‎

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ declare module "@tiptap/core" {
1717
}
1818
}
1919

20-
// Helper to create colgroup (copied from TipTap source)
20+
// Helper to create colgroup (copied from TipTap source).
21+
// Inline format puts each column width in the <col> `style`; class format stores it in
22+
// `data-col-width` instead and RichTextFormatStyle.scss renders it.
2123
function createColGroup(
2224
node: any,
23-
cellMinWidth: number
25+
cellMinWidth: number,
26+
styleDataFormat: "inline" | "class" = "inline"
2427
): { colgroup: any[]; tableWidth: string; tableMinWidth: string } {
2528
let totalWidth = 0;
2629
let fixedWidth = true;
@@ -50,7 +53,11 @@ function createColGroup(
5053
}
5154

5255
const safeCssWidth = safeSize(cssWidth);
53-
cols.push(["col", safeCssWidth ? { style: `width: ${safeCssWidth}` } : {}]);
56+
if (styleDataFormat === "class") {
57+
cols.push(["col", safeCssWidth ? { "data-col-width": safeCssWidth } : {}]);
58+
} else {
59+
cols.push(["col", safeCssWidth ? { style: `width: ${safeCssWidth}` } : {}]);
60+
}
5461
}
5562
}
5663

@@ -241,7 +248,11 @@ export const TableBackgroundColor = Table.extend<TableBackgroundColorOptions>({
241248
},
242249

243250
renderHTML({ node, HTMLAttributes }): DOMOutputSpec {
244-
const { colgroup, tableWidth, tableMinWidth } = createColGroup(node, this.options.cellMinWidth || 25);
251+
const { colgroup, tableWidth, tableMinWidth } = createColGroup(
252+
node,
253+
this.options.cellMinWidth || 25,
254+
this.options.styleDataFormat
255+
);
245256

246257
// Get style attributes from node (all validated before entering the style string)
247258
const explicitWidth = safeSize(node.attrs.width);
@@ -251,22 +262,22 @@ export const TableBackgroundColor = Table.extend<TableBackgroundColorOptions>({
251262
// Build the style string by merging table width, background, and border properties
252263
const segments: string[] = [];
253264

254-
// Explicit table width wins as the footprint; otherwise fall back to the
255-
// colwidth-derived width/min-width. Per-column min-width still comes from colgroup.
256-
if (explicitWidth) {
257-
segments.push(`width: ${explicitWidth}`);
258-
} else if (tableWidth) {
259-
segments.push(`width: ${tableWidth}`);
260-
} else if (tableMinWidth) {
261-
segments.push(`min-width: ${tableMinWidth}`);
262-
}
265+
if (this.options.styleDataFormat === "inline") {
266+
// Explicit table width wins as the footprint; otherwise fall back to the
267+
// colwidth-derived width/min-width. Per-column min-width still comes from colgroup.
268+
if (explicitWidth) {
269+
segments.push(`width: ${explicitWidth}`);
270+
} else if (tableWidth) {
271+
segments.push(`width: ${tableWidth}`);
272+
} else if (tableMinWidth) {
273+
segments.push(`min-width: ${tableMinWidth}`);
274+
}
263275

264-
// Explicit table minimum height (rows still grow with content)
265-
if (minHeight) {
266-
segments.push(`min-height: ${minHeight}`);
267-
}
276+
// Explicit table minimum height (rows still grow with content)
277+
if (minHeight) {
278+
segments.push(`min-height: ${minHeight}`);
279+
}
268280

269-
if (this.options.styleDataFormat === "inline") {
270281
// Add background color if present
271282
if (backgroundColor) {
272283
segments.push(`background-color: ${backgroundColor}`);
@@ -279,7 +290,9 @@ export const TableBackgroundColor = Table.extend<TableBackgroundColorOptions>({
279290

280291
const styleString = segments.join("; ");
281292

282-
// Build class-based attributes for border properties
293+
// Build class-based attributes. Class mode never emits a `style` attribute: explicit
294+
// sizes are stored as data attributes (rendered by RichTextFormatStyle.scss) and the
295+
// colwidth-derived width/min-width is not stored because it is recomputed.
283296
const classAttrs: Record<string, any> = {};
284297
if (this.options.styleDataFormat === "class") {
285298
const borderColor = safeColor(node.attrs.borderColor);
@@ -580,7 +593,7 @@ class TableBackgroundColorNodeView implements NodeView {
580593
}
581594

582595
// Create new colgroup
583-
const { colgroup } = createColGroup(this.node, this.cellMinWidth);
596+
const { colgroup } = createColGroup(this.node, this.cellMinWidth, this.styleDataFormat);
584597
const colgroupElement = this.createElementFromSpec(colgroup as unknown as DOMOutputSpec);
585598
this.table.insertBefore(colgroupElement, this.contentDOM);
586599
}

‎packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/TableClassFormat.spec.ts‎

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ function makeEditor(styleDataFormat: StyleFormat): Editor {
2222
});
2323
}
2424

25-
/** Places the cursor inside the first data cell. */
25+
/** Places the cursor inside the paragraph of the first data cell. */
2626
function placeCursorInFirstCell(editor: Editor): void {
2727
let pos = 1;
2828
editor.state.doc.descendants((node, nodePos) => {
2929
if (pos === 1 && node.type.name === "tableCell") {
30-
pos = nodePos + 1;
30+
pos = nodePos + 2;
3131
}
3232
return pos === 1;
3333
});
@@ -87,3 +87,142 @@ describe("TableBackgroundColor NodeView — class format borders", () => {
8787
expect(table.classList.contains("has-table-border")).toBe(false);
8888
});
8989
});
90+
91+
const SIZED_CLASS_HTML =
92+
'<table data-width="400px" data-min-height="120px"><tbody>' +
93+
'<tr><th data-cell-width="150px" data-cell-height="40px">H</th><th>H2</th></tr>' +
94+
'<tr><td data-cell-width="150px" data-cell-height="40px">A</td><td>B</td></tr>' +
95+
"</tbody></table>";
96+
97+
/** Opening tags of every table, col, td and th element in the serialized HTML. */
98+
function tableTags(html: string): string[] {
99+
return html.match(/<(table|col|td|th)\b[^>]*>/g) ?? [];
100+
}
101+
102+
describe("Table sizes — class format", () => {
103+
let editor: Editor;
104+
afterEach(() => editor?.destroy());
105+
106+
it("saves table, column and cell sizes as data attributes without any style attribute", () => {
107+
editor = makeEditor("class");
108+
editor.commands.setContent(SIZED_CLASS_HTML);
109+
const html = editor.getHTML();
110+
111+
expect(html).toContain('<table data-width="400px" data-min-height="120px">');
112+
expect(html).toContain('<colgroup><col data-col-width="150px"><col></colgroup>');
113+
expect(html).toContain('<th colspan="1" rowspan="1" data-cell-width="150px" data-cell-height="40px">');
114+
expect(html).toContain('<td colspan="1" rowspan="1" data-cell-width="150px" data-cell-height="40px">');
115+
const tags = tableTags(html);
116+
expect(tags.length).toBe(7);
117+
for (const tag of tags) {
118+
expect(tag).not.toContain("style=");
119+
}
120+
});
121+
122+
it("does not persist the column-derived table width", () => {
123+
editor = makeEditor("class");
124+
editor.commands.setContent(
125+
'<table><tbody><tr><td data-cell-width="150px">A</td><td data-cell-width="100px">B</td></tr></tbody></table>'
126+
);
127+
expect(tableTags(editor.getHTML())[0]).toBe("<table>");
128+
});
129+
130+
it("round-trips sizes through getHTML/setContent", () => {
131+
editor = makeEditor("class");
132+
editor.commands.setContent(SIZED_CLASS_HTML);
133+
const html = editor.getHTML();
134+
135+
editor.commands.setContent(html);
136+
expect(editor.getHTML()).toBe(html);
137+
const table = editor.state.doc.firstChild!;
138+
expect(table.attrs.width).toBe("400px");
139+
expect(table.attrs.minHeight).toBe("120px");
140+
expect(table.firstChild!.firstChild!.attrs.cellWidth).toBe("150px");
141+
expect(table.firstChild!.firstChild!.attrs.cellHeight).toBe("40px");
142+
});
143+
144+
it("still loads sizes from older class-format content that has inline style widths", () => {
145+
editor = makeEditor("class");
146+
editor.commands.setContent(
147+
'<table style="width: 400px; min-height: 120px;" data-width="400px" data-min-height="120px">' +
148+
'<colgroup><col style="width: 150px;"><col></colgroup><tbody>' +
149+
'<tr><td style="width: 150px; height: 40px;" data-cell-width="150px" data-cell-height="40px">A</td>' +
150+
'<td style="width: 80px; height: 30px;">B</td></tr>' +
151+
"</tbody></table>"
152+
);
153+
const table = editor.state.doc.firstChild!;
154+
const row = table.firstChild!;
155+
expect(table.attrs.width).toBe("400px");
156+
expect(table.attrs.minHeight).toBe("120px");
157+
expect(row.child(0).attrs.cellWidth).toBe("150px");
158+
expect(row.child(0).attrs.cellHeight).toBe("40px");
159+
// Cells without data attributes fall back to their inline style
160+
expect(row.child(1).attrs.cellWidth).toBe("80px");
161+
expect(row.child(1).attrs.cellHeight).toBe("30px");
162+
163+
// Saving again moves everything to data attributes
164+
const html = editor.getHTML();
165+
expect(html).toContain('<col data-col-width="150px"><col data-col-width="80px">');
166+
expect(html).toContain('data-cell-width="80px" data-cell-height="30px"');
167+
expect(html).not.toContain("style=");
168+
});
169+
170+
it("renders and updates sizes without ever setting a style attribute", () => {
171+
const setAttribute = jest.spyOn(Element.prototype, "setAttribute");
172+
try {
173+
editor = makeEditor("class");
174+
editor.commands.setContent(SIZED_CLASS_HTML);
175+
placeCursorInFirstCell(editor);
176+
editor.commands.setTableWidth("500px");
177+
editor.commands.setTableMinHeight("200px");
178+
editor.commands.setCellAttribute("cellWidth", "220px");
179+
editor.commands.setCellAttribute("cellHeight", "60px");
180+
const html = editor.getHTML();
181+
182+
expect(html).toContain('<table data-width="500px" data-min-height="200px">');
183+
expect(html).toContain('data-cell-width="220px" data-cell-height="60px"');
184+
expect(setAttribute.mock.calls.length).toBeGreaterThan(0);
185+
expect(setAttribute.mock.calls.filter(([name]) => name.toLowerCase() === "style")).toEqual([]);
186+
} finally {
187+
setAttribute.mockRestore();
188+
}
189+
});
190+
});
191+
192+
describe("Table sizes — inline format", () => {
193+
let editor: Editor;
194+
afterEach(() => editor?.destroy());
195+
196+
// Expected strings are the inline renderer's output before the class-format sizing change.
197+
it("keeps the explicit-size output unchanged", () => {
198+
editor = makeEditor("inline");
199+
editor.commands.setContent(
200+
'<table style="width: 400px; min-height: 120px"><tbody>' +
201+
'<tr><th style="width: 150px; height: 40px">H</th><th>H2</th></tr>' +
202+
'<tr><td style="width: 150px; height: 40px">A</td><td>B</td></tr>' +
203+
"</tbody></table>"
204+
);
205+
expect(editor.getHTML()).toBe(
206+
'<table style="width: 400px; min-height: 120px;"><colgroup><col style="width: 150px;"><col></colgroup><tbody>' +
207+
'<tr><th colspan="1" rowspan="1" style="width: 150px; height: 40px;"><p>H</p></th><th colspan="1" rowspan="1"><p>H2</p></th></tr>' +
208+
'<tr><td colspan="1" rowspan="1" style="width: 150px; height: 40px;"><p>A</p></td><td colspan="1" rowspan="1"><p>B</p></td></tr>' +
209+
"</tbody></table><p></p>"
210+
);
211+
});
212+
213+
it("keeps the column-derived width, colors and borders unchanged", () => {
214+
editor = makeEditor("inline");
215+
editor.commands.setContent(
216+
"<table><tbody><tr>" +
217+
'<td style="width: 150px; height: 40px; background-color: #00ff00; border-color: #d33115">A</td>' +
218+
'<td style="width: 30%">B</td>' +
219+
"</tr></tbody></table>"
220+
);
221+
expect(editor.getHTML()).toBe(
222+
'<table style="min-width: 175px;"><colgroup><col style="width: 150px;"><col style="width: 30%;"></colgroup><tbody><tr>' +
223+
'<td colspan="1" rowspan="1" style="width: 150px; height: 40px; background-color: rgb(0, 255, 0); border-style: solid; border-width: 1px; border-color: rgb(211, 49, 21);"><p>A</p></td>' +
224+
'<td colspan="1" rowspan="1" style="width: 30%;"><p>B</p></td>' +
225+
"</tr></tbody></table><p></p>"
226+
);
227+
});
228+
});

‎packages/pluggableWidgets/rich-text-web/src/extensions/tableCellStyling.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,18 +143,18 @@ export function renderCellHTML(
143143
const classAttrs: Record<string, any> = {};
144144
const segments: string[] = [];
145145

146-
// Column width applies in both formats (always an inline width on the cell)
147-
if (cellWidth) {
148-
segments.push(`width: ${cellWidth}`);
149-
}
150-
151-
// Cell height (minimum) applies in both formats
152-
if (cellHeight) {
153-
segments.push(`height: ${cellHeight}`);
154-
}
155-
156146
// Inline mode: build style string with border defaults
157147
if (styleDataFormat === "inline") {
148+
// Column width
149+
if (cellWidth) {
150+
segments.push(`width: ${cellWidth}`);
151+
}
152+
153+
// Cell height (minimum)
154+
if (cellHeight) {
155+
segments.push(`height: ${cellHeight}`);
156+
}
157+
158158
// Background color
159159
if (backgroundColor) {
160160
segments.push(`background-color: ${backgroundColor}`);
@@ -168,7 +168,8 @@ export function renderCellHTML(
168168

169169
const styleString = segments.join("; ");
170170

171-
// Class mode: use data attributes
171+
// Class mode: use data attributes only, never a `style` attribute (sizes are
172+
// rendered by RichTextFormatStyle.scss from data-cell-width/data-cell-height)
172173
if (styleDataFormat === "class") {
173174
if (cellWidth) {
174175
classAttrs["data-cell-width"] = cellWidth;

‎packages/pluggableWidgets/rich-text-web/src/ui/RichTextFormatStyle.scss‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,29 @@
116116
border-style: attr(data-border-style type(<custom-ident>), solid);
117117
border-color: attr(data-border-color type(<color>), var(--color-neutral-300, #d9d9d9));
118118
}
119+
120+
// Table, column and cell sizes, class mode. Class mode saves sizes as data attributes
121+
// (never a `style` attribute); the attribute selectors keep these rules off inline-mode
122+
// tables, which carry their sizes in `style`.
123+
table[data-width] {
124+
width: attr(data-width type(<length-percentage>));
125+
}
126+
127+
table[data-min-height] {
128+
min-height: attr(data-min-height type(<length-percentage>));
129+
}
130+
131+
col[data-col-width] {
132+
width: attr(data-col-width type(<length-percentage>));
133+
}
134+
135+
td[data-cell-width],
136+
th[data-cell-width] {
137+
width: attr(data-cell-width type(<length-percentage>));
138+
}
139+
140+
td[data-cell-height],
141+
th[data-cell-height] {
142+
height: attr(data-cell-height type(<length-percentage>));
143+
}
119144
}

0 commit comments

Comments
 (0)