From 3a6b16c5961e4d62bd726ee385f86ce5ed5cf911 Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 29 Mar 2026 19:40:07 -0300 Subject: [PATCH 01/16] fix(table-cell): add background color parsing from inline styles --- .../src/editors/v1/extensions/table-cell/table-cell.js | 6 ++++++ .../editors/v1/extensions/table-cell/table-cell.test.js | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js index 40fce5b770..222e161d3e 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js @@ -39,6 +39,7 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; +import { cssColorToHex } from '@core/utilities/cssColorToHex.js'; import { renderCellBorderStyle } from './helpers/renderCellBorderStyle.js'; /** @@ -154,6 +155,11 @@ export const TableCell = Node.create({ }, background: { + parseDOM: (element) => { + const color = cssColorToHex(element.style?.backgroundColor); + if (!color) return null; + return { color }; + }, renderDOM({ background }) { if (!background) return {}; // @ts-expect-error - background is known to be an object at runtime diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js index 3492941682..91cc14587e 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js @@ -15,4 +15,11 @@ describe('TableCell verticalAlign renderDOM', () => { style: 'vertical-align: bottom', }); }); + + it('parses background color from inline td style', () => { + const td = document.createElement('td'); + td.style.backgroundColor = 'rgb(255, 255, 0)'; + + expect(attributes.background.parseDOM(td)).toEqual({ color: 'ffff00' }); + }); }); From 10b70b87fd93c8f20b537d7a6faafe7f5c03852a Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 29 Mar 2026 19:49:14 -0300 Subject: [PATCH 02/16] fix(table-cell): dd cell margin parsing from inline styles --- .../table-cell/helpers/parseCellMargins.js | 39 +++++++++++++++++++ .../v1/extensions/table-cell/table-cell.js | 2 + .../extensions/table-cell/table-cell.test.js | 12 ++++++ 3 files changed, 53 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js b/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js new file mode 100644 index 0000000000..26691e8d6f --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js @@ -0,0 +1,39 @@ +// @ts-check +import { parseSizeUnit } from '@core/utilities/parseSizeUnit.js'; +import { halfPointToPixels } from '@core/super-converter/helpers.js'; + +/** + * Cell margins configuration in pixels. + * @typedef {Object} CellMargins + * @property {number} top - Top margin in pixels + * @property {number} right - Right margin in pixels + * @property {number} bottom - Bottom margin in pixels + * @property {number} left - Left margin in pixels + */ + +/** + * Parse cell margins from inline TD padding styles. + * + * @param {HTMLElement} element + * @returns {CellMargins} + */ +export const parseCellMargins = (element) => { + const { style } = element; + + const [topValuePt] = parseSizeUnit(style?.paddingTop); + const [rightValuePt] = parseSizeUnit(style?.paddingRight); + const [bottomValuePt] = parseSizeUnit(style?.paddingBottom); + const [leftValuePt] = parseSizeUnit(style?.paddingLeft); + + const top = halfPointToPixels(topValuePt); + const right = halfPointToPixels(rightValuePt); + const bottom = halfPointToPixels(bottomValuePt); + const left = halfPointToPixels(leftValuePt); + + return { + top: top ?? 0, + right: right ?? 0, + bottom: bottom ?? 0, + left: left ?? 0, + }; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js index 222e161d3e..fe62a7c7bd 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js @@ -40,6 +40,7 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; import { cssColorToHex } from '@core/utilities/cssColorToHex.js'; +import { parseCellMargins } from './helpers/parseCellMargins.js'; import { renderCellBorderStyle } from './helpers/renderCellBorderStyle.js'; /** @@ -178,6 +179,7 @@ export const TableCell = Node.create({ }, cellMargins: { + parseDOM: (element) => parseCellMargins(element), renderDOM({ cellMargins, borders }) { if (!cellMargins) return {}; const sides = ['top', 'right', 'bottom', 'left']; diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js index 91cc14587e..452ef4af7d 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js @@ -22,4 +22,16 @@ describe('TableCell verticalAlign renderDOM', () => { expect(attributes.background.parseDOM(td)).toEqual({ color: 'ffff00' }); }); + + it('parses padding into cellMargins from inline td style', () => { + const td = document.createElement('td'); + td.style.padding = '5pt'; + + expect(attributes.cellMargins.parseDOM(td)).toEqual({ + top: expect.closeTo(6.67, 3), + right: expect.closeTo(6.67, 3), + bottom: expect.closeTo(6.67, 3), + left: expect.closeTo(6.67, 3), + }); + }); }); From 65f7fdc7d9364d851de3efca89c858c5ea55d5ca Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 29 Mar 2026 20:44:16 -0300 Subject: [PATCH 03/16] fix(table-row): persists row height on pasted content from html --- .../table-row/helpers/parseRowHeight.js | 82 +++++++++++++++++ .../v1/extensions/table-row/table-row.js | 17 +++- .../v1/extensions/table-row/table-row.test.js | 89 +++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/editors/v1/extensions/table-row/helpers/parseRowHeight.js create mode 100644 packages/super-editor/src/editors/v1/extensions/table-row/table-row.test.js diff --git a/packages/super-editor/src/editors/v1/extensions/table-row/helpers/parseRowHeight.js b/packages/super-editor/src/editors/v1/extensions/table-row/helpers/parseRowHeight.js new file mode 100644 index 0000000000..8965fa9560 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/table-row/helpers/parseRowHeight.js @@ -0,0 +1,82 @@ +// @ts-check +import { parseSizeUnit } from '@core/utilities/parseSizeUnit.js'; +import { halfPointToPixels } from '@core/super-converter/helpers.js'; + +/** + * Parse CSS length-like value into pixels. + * Supports pt and px (or unitless numbers interpreted as px). + * + * @param {unknown} length + * @returns {number | undefined} + */ +const parseLengthPx = (length) => { + if (length == null) return undefined; + + if (typeof length === 'string') { + const trimmed = length.trim().toLowerCase(); + if (!trimmed || trimmed === 'auto') return undefined; + } + + const [value, unit] = parseSizeUnit(String(length)); + const numericValue = Number(value); + + const calculatedLength = unit === 'pt' ? halfPointToPixels(numericValue) : numericValue; + return calculatedLength; +}; + +/** + * Parse row height from explicit row-level style/attributes. + * + * @param {HTMLElement} element + * @returns {number | undefined} + */ +const parseExplicitRowHeight = (element) => { + const fromHeightStyle = parseLengthPx(element?.style?.height); + if (fromHeightStyle != null) return fromHeightStyle; + + const fromMinHeightStyle = parseLengthPx(element?.style?.minHeight); + if (fromMinHeightStyle != null) return fromMinHeightStyle; + + if (element?.hasAttribute?.('height')) { + const fromHeightAttr = parseLengthPx(element.getAttribute('height')); + if (fromHeightAttr != null) return fromHeightAttr; + } + + return undefined; +}; + +/** + * Parse row height by promoting the tallest explicit td/th height. + * + * @param {HTMLElement} element + * @returns {number | undefined} + */ +const parseTallestCellHeight = (element) => { + const cells = element?.querySelectorAll?.('td,th'); + if (!cells?.length) return undefined; + + let maxCellHeight; + for (const cellNode of Array.from(cells)) { + const cell = /** @type {HTMLElement} */ (cellNode); + const fromCellHeight = parseLengthPx(cell?.style?.height); + const fromCellMinHeight = parseLengthPx(cell?.style?.minHeight); + const fromCellHeightAttr = cell?.hasAttribute?.('height') ? parseLengthPx(cell.getAttribute('height')) : undefined; + + const candidate = fromCellHeight ?? fromCellMinHeight ?? fromCellHeightAttr; + if (candidate == null) continue; + if (maxCellHeight == null || candidate > maxCellHeight) maxCellHeight = candidate; + } + + return maxCellHeight; +}; + +/** + * Parse row height from a element. + * Priority: row height/min-height/height attr, then tallest td/th height. + * + * @param {HTMLElement} element + * @returns {number | undefined} + */ +export const parseRowHeight = (element) => { + return parseExplicitRowHeight(element) ?? parseTallestCellHeight(element); +}; diff --git a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js index 035e9b6740..623a5ca113 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js +++ b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js @@ -1,6 +1,8 @@ // @ts-nocheck import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; +import { pixelsToTwips } from '@core/super-converter/helpers.js'; +import { parseRowHeight } from './helpers/parseRowHeight.js'; /** * @typedef {Object} CnfStyle @@ -118,7 +120,20 @@ export const TableRow = Node.create({ /** * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 377-482 */ - tableRowProperties: { rendered: false }, + tableRowProperties: { + rendered: false, + parseDOM: (element) => { + const parsedHeightPx = parseRowHeight(element); + if (parsedHeightPx == null) return undefined; + + return { + rowHeight: { + value: pixelsToTwips(parsedHeightPx), + rule: 'atLeast', + }, + }; + }, + }, /** * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 472 */ diff --git a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.test.js b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.test.js new file mode 100644 index 0000000000..a7cc57fe47 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; + +import { TableRow } from './table-row.js'; + +describe('TableRow attributes', () => { + const attributes = TableRow.config.addAttributes.call(TableRow); + + it('omits row height style when value is missing', () => { + expect(attributes.rowHeight.renderDOM({})).toEqual({}); + expect(attributes.rowHeight.renderDOM({ rowHeight: null })).toEqual({}); + }); + + it('renders row height style in pixels', () => { + expect(attributes.rowHeight.renderDOM({ rowHeight: 20 })).toEqual({ + style: 'height: 20px', + }); + }); + + it('parses inline row height in pt into tableRowProperties (twips)', () => { + const tr = document.createElement('tr'); + tr.style.height = '15pt'; + + expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({ + rowHeight: { + value: expect.closeTo(300, 5), + rule: 'atLeast', + }, + }); + }); + + it('parses inline row height in px into tableRowProperties (twips)', () => { + const tr = document.createElement('tr'); + tr.style.height = '30px'; + + expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({ + rowHeight: { + value: 450, + rule: 'atLeast', + }, + }); + }); + + it('falls back to min-height when height is missing', () => { + const tr = document.createElement('tr'); + tr.style.minHeight = '12pt'; + + expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({ + rowHeight: { + value: expect.closeTo(240, 5), + rule: 'atLeast', + }, + }); + }); + + it('parses height attribute when styles are missing', () => { + const tr = document.createElement('tr'); + tr.setAttribute('height', '24'); + + expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({ + rowHeight: { + value: 360, + rule: 'atLeast', + }, + }); + }); + + it('returns undefined when no supported height value is present', () => { + const tr = document.createElement('tr'); + tr.style.height = 'auto'; + + expect(attributes.tableRowProperties.parseDOM(tr)).toBeUndefined(); + }); + + it('falls back to tallest td/th height when tr has no explicit height', () => { + const tr = document.createElement('tr'); + const tdA = document.createElement('td'); + tdA.style.height = '10pt'; + const tdB = document.createElement('td'); + tdB.style.minHeight = '22px'; + tr.append(tdA, tdB); + + expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({ + rowHeight: { + value: 330, + rule: 'atLeast', + }, + }); + }); +}); From ba53f9f4c3f80a849781a59c60f3c69f72dce998 Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 29 Mar 2026 20:48:55 -0300 Subject: [PATCH 04/16] fix(table-cell): persists cell verticallAlignment on paste from googleDocs --- .../src/editors/v1/extensions/table-cell/table-cell.js | 7 +++++++ .../editors/v1/extensions/table-cell/table-cell.test.js | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js index fe62a7c7bd..51fe0f22c6 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js @@ -171,6 +171,13 @@ export const TableCell = Node.create({ }, verticalAlign: { + parseDOM: (element) => { + const value = element.style?.verticalAlign; + if (!value || typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + if (normalized === 'middle') return 'center'; + return normalized; + }, renderDOM({ verticalAlign }) { if (!verticalAlign) return {}; const style = `vertical-align: ${verticalAlign}`; diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js index 452ef4af7d..9cd8c1f2c8 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js @@ -34,4 +34,11 @@ describe('TableCell verticalAlign renderDOM', () => { left: expect.closeTo(6.67, 3), }); }); + + it('parses vertical-align from inline td style', () => { + const td = document.createElement('td'); + td.style.verticalAlign = 'middle'; + + expect(attributes.verticalAlign.parseDOM(td)).toBe('center'); + }); }); From 1ba1a41dcb1fa979c6de53dd207c7a7f06f11288 Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 29 Mar 2026 20:51:06 -0300 Subject: [PATCH 05/16] fix(test): tests for color and margin were failing --- .../v1/extensions/table-cell/table-cell.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js index 9cd8c1f2c8..29e7b74fb5 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js @@ -20,7 +20,7 @@ describe('TableCell verticalAlign renderDOM', () => { const td = document.createElement('td'); td.style.backgroundColor = 'rgb(255, 255, 0)'; - expect(attributes.background.parseDOM(td)).toEqual({ color: 'ffff00' }); + expect(attributes.background.parseDOM(td)).toEqual({ color: '#ffff00' }); }); it('parses padding into cellMargins from inline td style', () => { @@ -28,10 +28,10 @@ describe('TableCell verticalAlign renderDOM', () => { td.style.padding = '5pt'; expect(attributes.cellMargins.parseDOM(td)).toEqual({ - top: expect.closeTo(6.67, 3), - right: expect.closeTo(6.67, 3), - bottom: expect.closeTo(6.67, 3), - left: expect.closeTo(6.67, 3), + top: 7, + right: 7, + bottom: 7, + left: 7, }); }); From ab5e67b4fc46e12a7ec550528cdaab760b48d87a Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 29 Mar 2026 21:02:48 -0300 Subject: [PATCH 06/16] feat(table-cell): add border parsing from inline styles and corresponding tests --- .../table-cell/helpers/parseCellBorders.js | 137 ++++++++++++++++++ .../v1/extensions/table-cell/table-cell.js | 2 + .../extensions/table-cell/table-cell.test.js | 17 +++ 3 files changed, 156 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js b/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js new file mode 100644 index 0000000000..9f8c6187f8 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js @@ -0,0 +1,137 @@ +// @ts-check +import { parseSizeUnit } from '@core/utilities/parseSizeUnit.js'; +import { cssColorToHex } from '@core/utilities/cssColorToHex.js'; +import { halfPointToPixels } from '@core/super-converter/helpers.js'; + +/** + * Parsed cell border shape used by table cell parseDOM. + * @typedef {Object} ParsedCellBorder + * @property {'none' | 'single' | 'dashed' | 'dotted'} val + * @property {number} size + * @property {string} color + * @property {string} style + */ + +/** + * Parsed borders object for each side. + * @typedef {Object} ParsedCellBorders + * @property {ParsedCellBorder} [top] + * @property {ParsedCellBorder} [right] + * @property {ParsedCellBorder} [bottom] + * @property {ParsedCellBorder} [left] + */ + +const STYLE_TOKEN_SET = new Set([ + 'none', + 'hidden', + 'dotted', + 'dashed', + 'solid', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', +]); + +/** + * Parse border width token into pixel number. + * + * @param {string} value + * @returns {number | null} + */ +const parseBorderWidth = (value) => { + const widthMatch = value.match(/(?:^|\s)(-?\d*\.?\d+(?:px|pt))(?=\s|$)/i); + if (!widthMatch?.[1]) return null; + + const [widthValue, widthUnit] = parseSizeUnit(widthMatch[1]); + + const numericWidth = Number(widthValue); + const size = widthUnit === 'pt' ? halfPointToPixels(numericWidth) : numericWidth; + return size; +}; + +/** + * Parse border style token. + * + * @param {string} value + * @returns {string | null} + */ +const parseBorderStyle = (value) => { + const styleMatch = value.match(/(?:^|\s)(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)(?=\s|$)/i); + return styleMatch?.[1] ? styleMatch[1].toLowerCase() : null; +}; + +/** + * Parse border color token. + * + * @param {string} value + * @returns {string | null} + */ +const parseBorderColor = (value) => { + const directColorMatch = value.match(/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-fA-F]{3,8}|var\([^)]+\))/i); + if (directColorMatch?.[1]) return directColorMatch[1]; + + const tokenColorMatch = value + .split(/\s+/) + .find((part) => /^[a-z]+$/i.test(part) && !STYLE_TOKEN_SET.has(part.toLowerCase())); + return tokenColorMatch || null; +}; + +/** + * Parse a single CSS border declaration. + * + * @param {string | undefined | null} rawValue + * @returns {ParsedCellBorder | null} + */ +const parseBorderValue = (rawValue) => { + if (!rawValue || typeof rawValue !== 'string') return null; + const value = rawValue.trim(); + if (!value) return null; + + if (value === 'none') { + return { val: 'none', size: 0, color: 'auto', style: 'none' }; + } + + const size = parseBorderWidth(value); + const style = parseBorderStyle(value); + const color = parseBorderColor(value); + + const hexColor = cssColorToHex(color); + if (style === 'none') { + return { val: 'none', size: 0, color: 'auto', style: 'none' }; + } + + if (size == null && !hexColor && !style) return null; + + return { + val: style === 'dashed' || style === 'dotted' ? style : 'single', + size: size ?? 1, + color: hexColor || 'auto', + style: style || 'solid', + }; +}; + +/** + * Parse cell borders from inline TD/TH styles. + * + * @param {HTMLElement} element + * @returns {ParsedCellBorders | null} + */ +export const parseCellBorders = (element) => { + const { style } = element; + + const top = parseBorderValue(style?.borderTop || style?.border); + const right = parseBorderValue(style?.borderRight || style?.border); + const bottom = parseBorderValue(style?.borderBottom || style?.border); + const left = parseBorderValue(style?.borderLeft || style?.border); + + if (!top && !right && !bottom && !left) return null; + + return { + ...(top ? { top } : {}), + ...(right ? { right } : {}), + ...(bottom ? { bottom } : {}), + ...(left ? { left } : {}), + }; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js index 51fe0f22c6..c23e5452e1 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js @@ -40,6 +40,7 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; import { cssColorToHex } from '@core/utilities/cssColorToHex.js'; +import { parseCellBorders } from './helpers/parseCellBorders.js'; import { parseCellMargins } from './helpers/parseCellMargins.js'; import { renderCellBorderStyle } from './helpers/renderCellBorderStyle.js'; @@ -207,6 +208,7 @@ export const TableCell = Node.create({ borders: { default: null, + parseDOM: (element) => parseCellBorders(element), renderDOM: ({ borders }) => { if (!borders) return {}; return renderCellBorderStyle(borders); diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js index 29e7b74fb5..54116b4c74 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js @@ -41,4 +41,21 @@ describe('TableCell verticalAlign renderDOM', () => { expect(attributes.verticalAlign.parseDOM(td)).toBe('center'); }); + + it('parses shorthand border into border attributes from inline td style', () => { + const td = document.createElement('td'); + td.style.border = '0.681818pt solid rgb(128, 128, 128)'; + + expect(attributes.borders.parseDOM(td)).toEqual({ + top: { val: 'single', size: 1, color: '#808080', style: 'solid' }, + right: { val: 'single', size: 1, color: '#808080', style: 'solid' }, + bottom: { val: 'single', size: 1, color: '#808080', style: 'solid' }, + left: { val: 'single', size: 1, color: '#808080', style: 'solid' }, + }); + }); + + it('returns null when border style is absent', () => { + const td = document.createElement('td'); + expect(attributes.borders.parseDOM(td)).toBeNull(); + }); }); From 48610069d5675c33c6245ec505d184b3e8b42d91 Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 29 Mar 2026 21:03:39 -0300 Subject: [PATCH 07/16] fix(table-row): prevent double interior borders --- .../dom/src/table/renderTableRow.test.ts | 47 +++++++++++++++++++ .../painters/dom/src/table/renderTableRow.ts | 14 ++++++ 2 files changed, 61 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index 868fec2ef2..6c7e3e903f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -126,6 +126,53 @@ describe('renderTableRow', () => { expect(call.borders?.right).toBeDefined(); }); + it('does not paint interior right border for explicit cell borders in collapsed mode', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + columnWidths: [100, 100], + rowMeasure: { + height: 20, + cells: [ + { width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 }, + { width: 100, height: 20, gridColumnStart: 1, colSpan: 1, rowSpan: 1 }, + ], + }, + row: { + id: 'row-1', + cells: [ + { + id: 'cell-1', + attrs: { + borders: { + top: { style: 'single', width: 2, color: '#123456' }, + right: { style: 'single', width: 2, color: '#123456' }, + bottom: { style: 'single', width: 2, color: '#123456' }, + left: { style: 'single', width: 2, color: '#123456' }, + }, + }, + blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }], + }, + { + id: 'cell-2', + blocks: [{ kind: 'paragraph', id: 'p2', runs: [] }], + }, + ], + }, + }) as never, + ); + + expect(renderTableCellMock).toHaveBeenCalledTimes(2); + const firstCall = renderTableCellMock.mock.calls[0][0] as { + borders?: { right?: unknown; left?: unknown }; + }; + + expect(firstCall.borders?.right).toBeUndefined(); + expect(firstCall.borders?.left).toBeDefined(); + }); + it('applies the table bottom border to a rowspan cell that reaches the final row', () => { renderTableRow( createDeps({ diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 1f335b7f92..f248edddbd 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -74,6 +74,20 @@ const resolveRenderedCellBorders = ({ const touchesBottomBoundary = cellBounds.touchesBottomEdge || continuesOnNext; if (hasExplicitBorders) { + if (cellSpacingPx === 0) { + // Collapsed model: avoid double interior borders by using single-owner sides. + // Keep explicit top/left (or table fallbacks), and only render right/bottom on table edges. + return { + top: resolveTableBorderValue(cellBorders.top, touchesTopBoundary ? tableBorders.top : tableBorders.insideH), + right: cellBounds.touchesRightEdge ? resolveTableBorderValue(cellBorders.right, tableBorders.right) : undefined, + bottom: touchesBottomBoundary ? resolveTableBorderValue(cellBorders.bottom, tableBorders.bottom) : undefined, + left: resolveTableBorderValue( + cellBorders.left, + cellBounds.touchesLeftEdge ? tableBorders.left : tableBorders.insideV, + ), + }; + } + return { top: resolveTableBorderValue(cellBorders.top, touchesTopBoundary ? tableBorders.top : tableBorders.insideH), right: resolveTableBorderValue(cellBorders.right, cellBounds.touchesRightEdge ? tableBorders.right : undefined), From 5b77732f99c0d72f990cace8143f8a1472acce85 Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 29 Mar 2026 22:07:23 -0300 Subject: [PATCH 08/16] refactor(table-cell): optimize border style parsing using a dynamic regex pattern --- .../v1/extensions/table-cell/helpers/parseCellBorders.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js b/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js index 9f8c6187f8..1730b7630f 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js @@ -34,6 +34,8 @@ const STYLE_TOKEN_SET = new Set([ 'outset', ]); +const STYLE_TOKEN_PATTERN = Array.from(STYLE_TOKEN_SET).join('|'); + /** * Parse border width token into pixel number. * @@ -58,7 +60,7 @@ const parseBorderWidth = (value) => { * @returns {string | null} */ const parseBorderStyle = (value) => { - const styleMatch = value.match(/(?:^|\s)(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)(?=\s|$)/i); + const styleMatch = value.match(new RegExp(`(?:^|\\s)(${STYLE_TOKEN_PATTERN})(?=\\s|$)`, 'i')); return styleMatch?.[1] ? styleMatch[1].toLowerCase() : null; }; From 27946cb63604d791c52d4a8632197fde1326781f Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 29 Mar 2026 22:41:37 -0300 Subject: [PATCH 09/16] refactor(table-cell): returns margins with value only --- .../table-cell/helpers/parseCellMargins.js | 47 ++++++++++++------- .../extensions/table-cell/table-cell.test.js | 5 ++ 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js b/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js index 26691e8d6f..2cf8f7c1e4 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js @@ -5,35 +5,48 @@ import { halfPointToPixels } from '@core/super-converter/helpers.js'; /** * Cell margins configuration in pixels. * @typedef {Object} CellMargins - * @property {number} top - Top margin in pixels - * @property {number} right - Right margin in pixels - * @property {number} bottom - Bottom margin in pixels - * @property {number} left - Left margin in pixels + * @property {number} [top] - Top margin in pixels + * @property {number} [right] - Right margin in pixels + * @property {number} [bottom] - Bottom margin in pixels + * @property {number} [left] - Left margin in pixels */ +/** + * Parse one CSS padding side into pixels. + * + * @param {string} sideValue + * @returns {number | undefined} + */ +const parseSide = (sideValue) => { + if (!sideValue) return undefined; + const [rawValue, unit] = parseSizeUnit(sideValue); + const numericValue = Number(rawValue); + const calculatedValue = unit === 'pt' ? halfPointToPixels(numericValue) : numericValue; + return calculatedValue; +}; + /** * Parse cell margins from inline TD padding styles. * * @param {HTMLElement} element - * @returns {CellMargins} + * @returns {CellMargins | null} */ export const parseCellMargins = (element) => { const { style } = element; - const [topValuePt] = parseSizeUnit(style?.paddingTop); - const [rightValuePt] = parseSizeUnit(style?.paddingRight); - const [bottomValuePt] = parseSizeUnit(style?.paddingBottom); - const [leftValuePt] = parseSizeUnit(style?.paddingLeft); + const top = parseSide(style?.paddingTop); + const right = parseSide(style?.paddingRight); + const bottom = parseSide(style?.paddingBottom); + const left = parseSide(style?.paddingLeft); - const top = halfPointToPixels(topValuePt); - const right = halfPointToPixels(rightValuePt); - const bottom = halfPointToPixels(bottomValuePt); - const left = halfPointToPixels(leftValuePt); + if (top == null && right == null && bottom == null && left == null) { + return null; + } return { - top: top ?? 0, - right: right ?? 0, - bottom: bottom ?? 0, - left: left ?? 0, + ...(top != null ? { top } : {}), + ...(right != null ? { right } : {}), + ...(bottom != null ? { bottom } : {}), + ...(left != null ? { left } : {}), }; }; diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js index 54116b4c74..1ff66dfe6e 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js @@ -35,6 +35,11 @@ describe('TableCell verticalAlign renderDOM', () => { }); }); + it('returns null for cellMargins when no inline padding is present', () => { + const td = document.createElement('td'); + expect(attributes.cellMargins.parseDOM(td)).toBeNull(); + }); + it('parses vertical-align from inline td style', () => { const td = document.createElement('td'); td.style.verticalAlign = 'middle'; From 199a1125d608db8081d156b48e65c23590f217eb Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 5 Apr 2026 12:56:22 -0300 Subject: [PATCH 10/16] test(table-cell): increase cooverage and refactor tests --- .../extensions/table-cell/table-cell.test.js | 206 ++++++++++++++---- 1 file changed, 164 insertions(+), 42 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js index 1ff66dfe6e..e698981aa4 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js @@ -1,66 +1,188 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { TableCell } from './table-cell.js'; -describe('TableCell verticalAlign renderDOM', () => { - const attributes = TableCell.config.addAttributes.call(TableCell); +describe('TableCell', () => { + let attributes; - it('omits style when verticalAlign is not provided', () => { - expect(attributes.verticalAlign.renderDOM({})).toEqual({}); - expect(attributes.verticalAlign.renderDOM({ verticalAlign: null })).toEqual({}); + beforeAll(() => { + attributes = TableCell.config.addAttributes.call(TableCell); }); - it('adds vertical-align style when attribute is set', () => { - expect(attributes.verticalAlign.renderDOM({ verticalAlign: 'bottom' })).toEqual({ - style: 'vertical-align: bottom', + describe('background', () => { + describe('parseDOM', () => { + it('parses rgb() background to hex', () => { + const td = document.createElement('td'); + td.style.backgroundColor = 'rgb(255, 255, 0)'; + expect(attributes.background.parseDOM(td)).toEqual({ color: '#ffff00' }); + }); + + it('returns null when background is unset', () => { + expect(attributes.background.parseDOM(document.createElement('td'))).toBeNull(); + }); }); - }); - it('parses background color from inline td style', () => { - const td = document.createElement('td'); - td.style.backgroundColor = 'rgb(255, 255, 0)'; + describe('renderDOM', () => { + it('omits style when background is absent', () => { + expect(attributes.background.renderDOM({})).toEqual({}); + expect(attributes.background.renderDOM({ background: null })).toEqual({}); + }); + + // renderDOM prefixes `#`; store channel hex without leading `#` to avoid `##` in CSS. + it('renders background-color for hex digits without leading #', () => { + expect(attributes.background.renderDOM({ background: { color: 'ffff00' } })).toEqual({ + style: 'background-color: #ffff00', + }); + }); - expect(attributes.background.parseDOM(td)).toEqual({ color: '#ffff00' }); + it('uses transparent when color is missing on background object', () => { + expect(attributes.background.renderDOM({ background: {} })).toEqual({ + style: 'background-color: transparent', + }); + }); + }); }); - it('parses padding into cellMargins from inline td style', () => { - const td = document.createElement('td'); - td.style.padding = '5pt'; + describe('verticalAlign', () => { + describe('parseDOM', () => { + it('maps middle to center', () => { + const td = document.createElement('td'); + td.style.verticalAlign = 'middle'; + expect(attributes.verticalAlign.parseDOM(td)).toBe('center'); + }); + + it('normalizes top and bottom', () => { + const topTd = document.createElement('td'); + topTd.style.verticalAlign = 'TOP'; + expect(attributes.verticalAlign.parseDOM(topTd)).toBe('top'); - expect(attributes.cellMargins.parseDOM(td)).toEqual({ - top: 7, - right: 7, - bottom: 7, - left: 7, + const bottomTd = document.createElement('td'); + bottomTd.style.verticalAlign = 'bottom'; + expect(attributes.verticalAlign.parseDOM(bottomTd)).toBe('bottom'); + }); + + it('returns null when vertical-align is missing', () => { + expect(attributes.verticalAlign.parseDOM(document.createElement('td'))).toBeNull(); + }); }); - }); - it('returns null for cellMargins when no inline padding is present', () => { - const td = document.createElement('td'); - expect(attributes.cellMargins.parseDOM(td)).toBeNull(); + describe('renderDOM', () => { + it('omits style when verticalAlign is unset', () => { + expect(attributes.verticalAlign.renderDOM({})).toEqual({}); + expect(attributes.verticalAlign.renderDOM({ verticalAlign: null })).toEqual({}); + }); + + it('emits vertical-align CSS', () => { + expect(attributes.verticalAlign.renderDOM({ verticalAlign: 'bottom' })).toEqual({ + style: 'vertical-align: bottom', + }); + }); + }); }); - it('parses vertical-align from inline td style', () => { - const td = document.createElement('td'); - td.style.verticalAlign = 'middle'; + describe('cellMargins', () => { + describe('parseDOM', () => { + it('parses uniform padding in pt', () => { + const td = document.createElement('td'); + td.style.padding = '5pt'; + expect(attributes.cellMargins.parseDOM(td)).toEqual({ + top: 7, + right: 7, + bottom: 7, + left: 7, + }); + }); - expect(attributes.verticalAlign.parseDOM(td)).toBe('center'); - }); + it('parses per-side padding', () => { + const td = document.createElement('td'); + td.style.paddingTop = '5pt'; + td.style.paddingRight = '10pt'; + td.style.paddingBottom = '15pt'; + expect(attributes.cellMargins.parseDOM(td)).toEqual({ + top: 7, + right: 13, + bottom: 20, + }); + }); - it('parses shorthand border into border attributes from inline td style', () => { - const td = document.createElement('td'); - td.style.border = '0.681818pt solid rgb(128, 128, 128)'; + it('returns null when no padding is set', () => { + expect(attributes.cellMargins.parseDOM(document.createElement('td'))).toBeNull(); + }); + }); + + describe('renderDOM', () => { + it('returns {} when cellMargins is absent', () => { + expect(attributes.cellMargins.renderDOM({})).toEqual({}); + }); - expect(attributes.borders.parseDOM(td)).toEqual({ - top: { val: 'single', size: 1, color: '#808080', style: 'solid' }, - right: { val: 'single', size: 1, color: '#808080', style: 'solid' }, - bottom: { val: 'single', size: 1, color: '#808080', style: 'solid' }, - left: { val: 'single', size: 1, color: '#808080', style: 'solid' }, + it('subtracts non-none border width from padding', () => { + const result = attributes.cellMargins.renderDOM({ + cellMargins: { top: 12, right: 8 }, + borders: { + top: { val: 'single', size: 3, color: '#000', style: 'solid' }, + right: { val: 'none', size: 0, color: 'auto', style: 'none' }, + }, + }); + expect(result.style).toContain('padding-top: 9px'); + expect(result.style).toContain('padding-right: 8px'); + }); }); }); - it('returns null when border style is absent', () => { - const td = document.createElement('td'); - expect(attributes.borders.parseDOM(td)).toBeNull(); + describe('borders', () => { + describe('parseDOM', () => { + it('applies shorthand border to all sides', () => { + const td = document.createElement('td'); + td.style.border = '1px solid rgb(0, 0, 0)'; + expect(attributes.borders.parseDOM(td)).toEqual({ + top: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + right: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + bottom: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + left: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + }); + }); + + it('uses per-side rules when they override shorthand', () => { + const td = document.createElement('td'); + td.style.border = '1px solid rgb(0, 0, 0)'; + td.style.borderTop = '2px dashed rgb(255, 0, 0)'; + td.style.borderRight = '3px dotted rgb(0, 0, 255)'; + td.style.borderBottom = '4px double rgb(0, 128, 0)'; + expect(attributes.borders.parseDOM(td)).toEqual({ + top: { val: 'dashed', size: 2, color: '#ff0000', style: 'dashed' }, + right: { val: 'dotted', size: 3, color: '#0000ff', style: 'dotted' }, + bottom: { val: 'single', size: 4, color: '#008000', style: 'double' }, + left: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + }); + }); + + it('returns null when no border is set', () => { + expect(attributes.borders.parseDOM(document.createElement('td'))).toBeNull(); + }); + }); + + describe('renderDOM', () => { + it('returns {} when borders are absent', () => { + expect(attributes.borders.renderDOM({})).toEqual({}); + }); + + it('delegates to renderCellBorderStyle (solid px, auto → black)', () => { + const result = attributes.borders.renderDOM({ + borders: { + top: { val: 'single', size: 1, color: 'auto', style: 'solid' }, + }, + }); + expect(result.style).toContain('border-top: 1px solid black'); + }); + + it('renders border none without width', () => { + const result = attributes.borders.renderDOM({ + borders: { + top: { val: 'none', size: 0, color: 'auto', style: 'none' }, + }, + }); + expect(result.style).toContain('border-top: none'); + }); + }); }); }); From 27ad31650b5158b562fcf8bd7de272bdb5d55702 Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 5 Apr 2026 13:04:29 -0300 Subject: [PATCH 11/16] docs(renderTableRow): clarify assumptions about shared interior cell borders --- packages/layout-engine/painters/dom/src/table/renderTableRow.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index f248edddbd..764f5873d2 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -77,6 +77,8 @@ const resolveRenderedCellBorders = ({ if (cellSpacingPx === 0) { // Collapsed model: avoid double interior borders by using single-owner sides. // Keep explicit top/left (or table fallbacks), and only render right/bottom on table edges. + // Assumes shared interior edges specify the same border on both adjacent cells (e.g. Google Docs + // round-trips this way). Asymmetric (only one cell’s side set) may miss a line until we add conflict resolution. return { top: resolveTableBorderValue(cellBorders.top, touchesTopBoundary ? tableBorders.top : tableBorders.insideH), right: cellBounds.touchesRightEdge ? resolveTableBorderValue(cellBorders.right, tableBorders.right) : undefined, From 36fd587b1d7d22d00018a224e90eef3100fe2658 Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 5 Apr 2026 13:13:10 -0300 Subject: [PATCH 12/16] test(renderTableRow): add test for non-final row border behavior in collapsed mode --- .../dom/src/table/renderTableRow.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index 6c7e3e903f..4c1661ced4 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -173,6 +173,42 @@ describe('renderTableRow', () => { expect(firstCall.borders?.left).toBeDefined(); }); + it('does not paint interior bottom border for explicit cell borders in collapsed mode on non-final row', () => { + const explicit = { + top: { style: 'single' as const, width: 2, color: '#123456' }, + right: { style: 'single' as const, width: 2, color: '#123456' }, + bottom: { style: 'single' as const, width: 2, color: '#123456' }, + left: { style: 'single' as const, width: 2, color: '#123456' }, + }; + renderTableRow( + createDeps({ + rowIndex: 2, + totalRows: 5, + cellSpacingPx: 0, + columnWidths: [100], + rowMeasure: { + height: 20, + cells: [{ width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 }], + }, + row: { + id: 'row-1', + cells: [ + { + id: 'cell-1', + attrs: { borders: explicit }, + blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }], + }, + ], + }, + }) as never, + ); + + expect(renderTableCellMock).toHaveBeenCalledTimes(1); + const call = getRenderedCellCall(); + expect(call.borders?.bottom).toBeUndefined(); + expect(call.borders?.top).toBeDefined(); + }); + it('applies the table bottom border to a rowspan cell that reaches the final row', () => { renderTableRow( createDeps({ From f8a6982c428cb7057b51d448196dba16775b63da Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Sun, 5 Apr 2026 18:20:05 -0300 Subject: [PATCH 13/16] test(table-cell): add integration tests for HTML paste handling of table cell styles --- .../table-cell-paste.integration.test.js | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/extensions/table-cell/table-cell-paste.integration.test.js diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell-paste.integration.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell-paste.integration.test.js new file mode 100644 index 0000000000..df5c0c45b9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell-paste.integration.test.js @@ -0,0 +1,162 @@ +/** + * HTML paste → ProseMirror JSON for `tableCell`. + * + * ParseDOM fills `attrs.borders`; the table extension then migrates them to + * `attrs.tableCellProperties.borders` (OOXML, eighths of a point) and sets `attrs.borders` to null. + * Border assertions read that migrated shape. + */ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { handleHtmlPaste } from '@core/InputRule.js'; +import { initTestEditor, loadTestDataForEditorTests } from '../../tests/helpers/helpers.js'; + +let docData; +let editor; + +/** Same as legacyBorderMigration: px → OOXML eighths of a point. */ +const pxToEighthPt = (px) => Math.round((px / (96 / 72)) * 8); + +beforeAll(async () => { + docData = await loadTestDataForEditorTests('blank-doc.docx'); +}); + +afterEach(() => { + editor?.destroy(); + editor = null; +}); + +/** + * Fresh editor from blank doc, paste HTML, return `tableCell` / `tableHeader` nodes (depth-first) + * from the first `table` in the document. + */ +function pasteTableCells(html) { + ({ editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + mode: 'docx', + })); + + expect(handleHtmlPaste(html, editor)).toBe(true); + + const table = editor.getJSON().content?.find((n) => n?.type === 'table'); + expect(table).toBeTruthy(); + + const cells = []; + const collectTableCellNodesInOrder = (node) => { + if (node?.type === 'tableCell' || node?.type === 'tableHeader') { + cells.push(node); + } + for (const child of node?.content ?? []) { + collectTableCellNodesInOrder(child); + } + }; + collectTableCellNodesInOrder(table); + + return cells; +} + +describe('table-cell HTML paste integration', () => { + it('parses td border shorthand: solid, dashed, dotted, widths and colors', () => { + const cells = pasteTableCells(` + + + + + + + + + +
AB
CD
+ `); + + expect(cells).toHaveLength(4); + + const b0 = cells[0].attrs?.tableCellProperties?.borders; + expect(b0?.top).toMatchObject({ val: 'single', color: '#ff0000' }); + expect(b0?.top?.size).toBe(pxToEighthPt(2)); + for (const side of ['top', 'right', 'bottom', 'left']) { + expect(b0?.[side]).toMatchObject({ val: 'single', color: '#ff0000' }); + } + + const b1 = cells[1].attrs?.tableCellProperties?.borders; + expect(b1?.top).toMatchObject({ val: 'dashed', color: '#0000ff' }); + expect(b1?.top?.size).toBe(pxToEighthPt(1)); + + const b2 = cells[2].attrs?.tableCellProperties?.borders; + expect(b2?.top).toMatchObject({ val: 'dotted', color: '#00aa00' }); + expect(b2?.top?.size).toBe(pxToEighthPt(3)); + + expect(cells[3].attrs?.tableCellProperties?.borders).toBeUndefined(); + expect(cells[3].attrs?.borders).toBeNull(); + }); + + it('parses per-side borders when sides differ', () => { + const cells = pasteTableCells(` + + +
X
+ `); + + expect(cells).toHaveLength(1); + const b = cells[0].attrs?.tableCellProperties?.borders; + expect(b?.top).toMatchObject({ val: 'dashed', color: '#ff0000' }); + expect(b?.top?.size).toBe(pxToEighthPt(2)); + expect(b?.right).toMatchObject({ val: 'dotted', color: '#0000ff' }); + expect(b?.bottom).toMatchObject({ val: 'single', color: 'auto' }); + expect(b?.left).toMatchObject({ val: 'single', color: 'auto' }); + }); + + it('parses different background colors per row and cell', () => { + const cells = pasteTableCells(` + + + + + + + + + +
R0C0R0C1
R1C0R1C1 plain
+ `); + + expect(cells).toHaveLength(4); + expect(cells[0].attrs?.background).toEqual({ color: '#ffff00' }); + expect(cells[1].attrs?.background).toEqual({ color: '#00ffff' }); + expect(cells[2].attrs?.background).toEqual({ color: '#800080' }); + expect(cells[3].attrs?.background == null).toBe(true); + }); + + it('parses vertical-align from td style', () => { + const cells = pasteTableCells(` + + + + +
MBT
+ `); + + expect(cells).toHaveLength(3); + expect(cells[0].attrs?.verticalAlign).toBe('center'); + expect(cells[1].attrs?.verticalAlign).toBe('bottom'); + expect(cells[2].attrs?.verticalAlign == null).toBe(true); + }); + + it('parses padding into cellMargins on pasted cells', () => { + const cells = pasteTableCells(` + + +
P
+ `); + + expect(cells[0].attrs?.cellMargins).toEqual({ + top: 7, + right: 7, + bottom: 7, + left: 7, + }); + }); +}); From fa48f6be8f18484a5f7b7359698713ac54a4906c Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Tue, 7 Apr 2026 20:54:58 -0300 Subject: [PATCH 14/16] feat(table): add parsing for table header and cell --- .../helpers => shared}/parseCellBorders.js | 2 +- .../helpers => shared}/parseCellMargins.js | 2 +- .../shared/parseCellVerticalAlign.js | 16 + ...able-cell-header-paste.integration.test.js | 345 ++++++++++++++++++ .../table-cell-paste.integration.test.js | 162 -------- .../v1/extensions/table-cell/table-cell.js | 13 +- .../extensions/table-header/table-header.js | 13 + .../table-header/table-header.test.js | 191 ++++++++++ 8 files changed, 571 insertions(+), 173 deletions(-) rename packages/super-editor/src/editors/v1/extensions/{table-cell/helpers => shared}/parseCellBorders.js (98%) rename packages/super-editor/src/editors/v1/extensions/{table-cell/helpers => shared}/parseCellMargins.js (96%) create mode 100644 packages/super-editor/src/editors/v1/extensions/shared/parseCellVerticalAlign.js create mode 100644 packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js delete mode 100644 packages/super-editor/src/editors/v1/extensions/table-cell/table-cell-paste.integration.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js b/packages/super-editor/src/editors/v1/extensions/shared/parseCellBorders.js similarity index 98% rename from packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js rename to packages/super-editor/src/editors/v1/extensions/shared/parseCellBorders.js index 1730b7630f..f7f0e0b51c 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellBorders.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/parseCellBorders.js @@ -4,7 +4,7 @@ import { cssColorToHex } from '@core/utilities/cssColorToHex.js'; import { halfPointToPixels } from '@core/super-converter/helpers.js'; /** - * Parsed cell border shape used by table cell parseDOM. + * Parsed cell border shape used by table cell / header parseDOM. * @typedef {Object} ParsedCellBorder * @property {'none' | 'single' | 'dashed' | 'dotted'} val * @property {number} size diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js b/packages/super-editor/src/editors/v1/extensions/shared/parseCellMargins.js similarity index 96% rename from packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js rename to packages/super-editor/src/editors/v1/extensions/shared/parseCellMargins.js index 2cf8f7c1e4..634961d818 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/parseCellMargins.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/parseCellMargins.js @@ -26,7 +26,7 @@ const parseSide = (sideValue) => { }; /** - * Parse cell margins from inline TD padding styles. + * Parse cell margins from inline TD/TH padding styles. * * @param {HTMLElement} element * @returns {CellMargins | null} diff --git a/packages/super-editor/src/editors/v1/extensions/shared/parseCellVerticalAlign.js b/packages/super-editor/src/editors/v1/extensions/shared/parseCellVerticalAlign.js new file mode 100644 index 0000000000..64e25e56a7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/shared/parseCellVerticalAlign.js @@ -0,0 +1,16 @@ +// @ts-check + +/** + * Parse `vertical-align` from inline styles on table cells (TD/TH), e.g. pasted HTML. + * Maps CSS `middle` to the schema value `center`. + * + * @param {HTMLElement} element + * @returns {string | null} + */ +export function parseCellVerticalAlignFromStyle(element) { + const value = element.style?.verticalAlign; + if (!value || typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + if (normalized === 'middle') return 'center'; + return normalized; +} diff --git a/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js b/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js new file mode 100644 index 0000000000..262b37cb3d --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js @@ -0,0 +1,345 @@ +/** + * Integration: HTML paste → ProseMirror JSON for `tableCell` (``) and `tableHeader` (``). + * + * ParseDOM fills `attrs.borders`; the table extension then migrates them to + * `attrs.tableCellProperties.borders` (OOXML, eighths of a point) and sets `attrs.borders` to null. + * Border assertions read that migrated shape. + */ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { handleHtmlPaste } from '@core/InputRule.js'; +import { initTestEditor, loadTestDataForEditorTests } from '../../tests/helpers/helpers.js'; + +let docData; +let editor; + +/** Same as legacyBorderMigration: px → OOXML eighths of a point. */ +const pxToEighthPt = (px) => Math.round((px / (96 / 72)) * 8); + +beforeAll(async () => { + docData = await loadTestDataForEditorTests('blank-doc.docx'); +}); + +afterEach(() => { + editor?.destroy(); + editor = null; +}); + +/** + * Fresh editor from blank doc, paste HTML, return `tableCell` / `tableHeader` nodes (depth-first) + * from the first `table` in the document. + */ +function pasteTableCells(html) { + ({ editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + mode: 'docx', + })); + + expect(handleHtmlPaste(html, editor)).toBe(true); + + const table = editor.getJSON().content?.find((n) => n?.type === 'table'); + expect(table).toBeTruthy(); + + const cells = []; + const collectTableCellNodesInOrder = (node) => { + if (node?.type === 'tableCell' || node?.type === 'tableHeader') { + cells.push(node); + } + for (const child of node?.content ?? []) { + collectTableCellNodesInOrder(child); + } + }; + collectTableCellNodesInOrder(table); + + return cells; +} + +describe('tableCell & tableHeader HTML paste integration', () => { + it('parses td border shorthand: solid, dashed, dotted, widths and colors', () => { + const cells = pasteTableCells(` + + + + + + + + + +
AB
CD
+ `); + + expect(cells).toHaveLength(4); + + const b0 = cells[0].attrs?.tableCellProperties?.borders; + expect(b0?.top).toMatchObject({ val: 'single', color: '#ff0000' }); + expect(b0?.top?.size).toBe(pxToEighthPt(2)); + for (const side of ['top', 'right', 'bottom', 'left']) { + expect(b0?.[side]).toMatchObject({ val: 'single', color: '#ff0000' }); + } + + const b1 = cells[1].attrs?.tableCellProperties?.borders; + expect(b1?.top).toMatchObject({ val: 'dashed', color: '#0000ff' }); + expect(b1?.top?.size).toBe(pxToEighthPt(1)); + + const b2 = cells[2].attrs?.tableCellProperties?.borders; + expect(b2?.top).toMatchObject({ val: 'dotted', color: '#00aa00' }); + expect(b2?.top?.size).toBe(pxToEighthPt(3)); + + expect(cells[3].attrs?.tableCellProperties?.borders).toBeUndefined(); + expect(cells[3].attrs?.borders).toBeNull(); + }); + + it('parses per-side borders when sides differ', () => { + const cells = pasteTableCells(` + + +
X
+ `); + + expect(cells).toHaveLength(1); + const b = cells[0].attrs?.tableCellProperties?.borders; + expect(b?.top).toMatchObject({ val: 'dashed', color: '#ff0000' }); + expect(b?.top?.size).toBe(pxToEighthPt(2)); + expect(b?.right).toMatchObject({ val: 'dotted', color: '#0000ff' }); + expect(b?.bottom).toMatchObject({ val: 'single', color: 'auto' }); + expect(b?.left).toMatchObject({ val: 'single', color: 'auto' }); + }); + + it('parses different background colors per row and cell', () => { + const cells = pasteTableCells(` + + + + + + + + + +
R0C0R0C1
R1C0R1C1 plain
+ `); + + expect(cells).toHaveLength(4); + expect(cells[0].attrs?.background).toEqual({ color: '#ffff00' }); + expect(cells[1].attrs?.background).toEqual({ color: '#00ffff' }); + expect(cells[2].attrs?.background).toEqual({ color: '#800080' }); + expect(cells[3].attrs?.background == null).toBe(true); + }); + + it('parses vertical-align from td style', () => { + const cells = pasteTableCells(` + + + + +
MBT
+ `); + + expect(cells).toHaveLength(3); + expect(cells[0].attrs?.verticalAlign).toBe('center'); + expect(cells[1].attrs?.verticalAlign).toBe('bottom'); + expect(cells[2].attrs?.verticalAlign == null).toBe(true); + }); + + it('parses padding into cellMargins on pasted cells', () => { + const cells = pasteTableCells(` + + +
P
+ `); + + expect(cells[0].attrs?.cellMargins).toEqual({ + top: 7, + right: 7, + bottom: 7, + left: 7, + }); + }); + + it('parses Google Docs–style header row: th keeps padding, background, borders, vertical-align', () => { + const cells = pasteTableCells(` + + + + + + + + + + +
H1H2
AB
+ `); + + expect(cells).toHaveLength(4); + expect(cells[0].type).toBe('tableHeader'); + expect(cells[1].type).toBe('tableHeader'); + + expect(cells[0].attrs?.cellMargins).toEqual({ + top: 7, + right: 7, + bottom: 7, + left: 7, + }); + expect(cells[0].attrs?.background).toEqual({ color: '#c8dcff' }); + expect(cells[0].attrs?.verticalAlign).toBe('bottom'); + + const hb = cells[0].attrs?.tableCellProperties?.borders; + expect(hb?.top).toMatchObject({ val: 'dashed', color: '#0000c8' }); + expect(hb?.top?.size).toBe(pxToEighthPt(2)); + + expect(cells[1].attrs?.cellMargins).toEqual({ + top: 8, + right: 8, + bottom: 8, + left: 8, + }); + + expect(cells[2].type).toBe('tableCell'); + expect(cells[3].type).toBe('tableCell'); + }); + + describe('mixed tableHeader and tableCell layouts', () => { + it('parses tbody rows that start with th (row headers) then td', () => { + const cells = pasteTableCells(` + + + + + + + + + + + +
Label1A1A2
Label2B1B2
+ `); + + expect(cells).toHaveLength(6); + expect(cells.map((c) => c.type)).toEqual([ + 'tableHeader', + 'tableCell', + 'tableCell', + 'tableHeader', + 'tableCell', + 'tableCell', + ]); + + expect(cells[0].attrs?.background).toEqual({ color: '#e6e6e6' }); + expect(cells[1].attrs?.cellMargins).toEqual({ + top: 6, + right: 6, + bottom: 6, + left: 6, + }); + expect(cells[2].attrs?.cellMargins == null).toBe(true); + + expect(cells[3].attrs?.background == null).toBe(true); + const bB1 = cells[4].attrs?.tableCellProperties?.borders; + expect(bB1?.top).toMatchObject({ val: 'single', color: '#008000' }); + }); + + it('parses a single body row mixing th and td with different inline styles', () => { + const cells = pasteTableCells(` + + + + + +
HD1MidD2
+ `); + + expect(cells).toHaveLength(4); + expect(cells.map((c) => c.type)).toEqual(['tableHeader', 'tableCell', 'tableHeader', 'tableCell']); + + expect(cells[0].attrs?.verticalAlign).toBe('top'); + const hBorder = cells[0].attrs?.tableCellProperties?.borders; + expect(hBorder?.top).toMatchObject({ val: 'single', color: '#c80000' }); + + expect(cells[1].attrs?.cellMargins?.top).toBe(7); + expect(cells[1].attrs?.background).toEqual({ color: '#00c8c8' }); + + expect(cells[2].attrs?.cellMargins).toEqual({ + top: 2, + right: 2, + bottom: 2, + left: 2, + }); + expect(cells[3].attrs?.background == null).toBe(true); + }); + + it('parses two-row thead plus body: each header row th, then body td', () => { + const cells = pasteTableCells(` + + + + + + + + + + + + + + +
DeptCode
Subtotal
EngE1
SalesS1
+ `); + + // Row0: 2 headers; row1: 1 header (colspan 2); body: 2 rows × 2 cells = 7 cells + expect(cells).toHaveLength(7); + expect(cells[0].type).toBe('tableHeader'); + expect(cells[1].type).toBe('tableHeader'); + expect(cells[0].attrs?.background).toEqual({ color: '#6464c8' }); + expect(cells[1].attrs?.background == null).toBe(true); + + expect(cells[2].type).toBe('tableHeader'); + expect(cells[2].attrs?.colspan).toBe(2); + expect(cells[2].attrs?.cellMargins?.top).toBe(3); + + expect(cells.slice(3).every((c) => c.type === 'tableCell')).toBe(true); + expect(cells[3].attrs?.background == null).toBe(true); + }); + + it('parses thead column headers and tbody with styled td next to plain th row header', () => { + const cells = pasteTableCells(` + + + + + + + + + + + + + + + +
JanFeb
Row A1020
+ `); + + expect(cells).toHaveLength(6); + expect(cells[0].type).toBe('tableHeader'); + expect(cells[1].type).toBe('tableHeader'); + expect(cells[2].type).toBe('tableHeader'); + + const janBottom = cells[1].attrs?.tableCellProperties?.borders?.bottom; + expect(janBottom).toMatchObject({ val: 'single', color: '#0000ff' }); + expect(janBottom?.size).toBe(pxToEighthPt(2)); + + expect(cells[3].type).toBe('tableHeader'); + expect(cells[4].type).toBe('tableCell'); + expect(cells[4].attrs?.background).toEqual({ color: '#fff0f0' }); + expect(cells[5].type).toBe('tableCell'); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell-paste.integration.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell-paste.integration.test.js deleted file mode 100644 index df5c0c45b9..0000000000 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell-paste.integration.test.js +++ /dev/null @@ -1,162 +0,0 @@ -/** - * HTML paste → ProseMirror JSON for `tableCell`. - * - * ParseDOM fills `attrs.borders`; the table extension then migrates them to - * `attrs.tableCellProperties.borders` (OOXML, eighths of a point) and sets `attrs.borders` to null. - * Border assertions read that migrated shape. - */ -import { afterEach, beforeAll, describe, expect, it } from 'vitest'; - -import { handleHtmlPaste } from '@core/InputRule.js'; -import { initTestEditor, loadTestDataForEditorTests } from '../../tests/helpers/helpers.js'; - -let docData; -let editor; - -/** Same as legacyBorderMigration: px → OOXML eighths of a point. */ -const pxToEighthPt = (px) => Math.round((px / (96 / 72)) * 8); - -beforeAll(async () => { - docData = await loadTestDataForEditorTests('blank-doc.docx'); -}); - -afterEach(() => { - editor?.destroy(); - editor = null; -}); - -/** - * Fresh editor from blank doc, paste HTML, return `tableCell` / `tableHeader` nodes (depth-first) - * from the first `table` in the document. - */ -function pasteTableCells(html) { - ({ editor } = initTestEditor({ - content: docData.docx, - media: docData.media, - mediaFiles: docData.mediaFiles, - fonts: docData.fonts, - mode: 'docx', - })); - - expect(handleHtmlPaste(html, editor)).toBe(true); - - const table = editor.getJSON().content?.find((n) => n?.type === 'table'); - expect(table).toBeTruthy(); - - const cells = []; - const collectTableCellNodesInOrder = (node) => { - if (node?.type === 'tableCell' || node?.type === 'tableHeader') { - cells.push(node); - } - for (const child of node?.content ?? []) { - collectTableCellNodesInOrder(child); - } - }; - collectTableCellNodesInOrder(table); - - return cells; -} - -describe('table-cell HTML paste integration', () => { - it('parses td border shorthand: solid, dashed, dotted, widths and colors', () => { - const cells = pasteTableCells(` - - - - - - - - - -
AB
CD
- `); - - expect(cells).toHaveLength(4); - - const b0 = cells[0].attrs?.tableCellProperties?.borders; - expect(b0?.top).toMatchObject({ val: 'single', color: '#ff0000' }); - expect(b0?.top?.size).toBe(pxToEighthPt(2)); - for (const side of ['top', 'right', 'bottom', 'left']) { - expect(b0?.[side]).toMatchObject({ val: 'single', color: '#ff0000' }); - } - - const b1 = cells[1].attrs?.tableCellProperties?.borders; - expect(b1?.top).toMatchObject({ val: 'dashed', color: '#0000ff' }); - expect(b1?.top?.size).toBe(pxToEighthPt(1)); - - const b2 = cells[2].attrs?.tableCellProperties?.borders; - expect(b2?.top).toMatchObject({ val: 'dotted', color: '#00aa00' }); - expect(b2?.top?.size).toBe(pxToEighthPt(3)); - - expect(cells[3].attrs?.tableCellProperties?.borders).toBeUndefined(); - expect(cells[3].attrs?.borders).toBeNull(); - }); - - it('parses per-side borders when sides differ', () => { - const cells = pasteTableCells(` - - -
X
- `); - - expect(cells).toHaveLength(1); - const b = cells[0].attrs?.tableCellProperties?.borders; - expect(b?.top).toMatchObject({ val: 'dashed', color: '#ff0000' }); - expect(b?.top?.size).toBe(pxToEighthPt(2)); - expect(b?.right).toMatchObject({ val: 'dotted', color: '#0000ff' }); - expect(b?.bottom).toMatchObject({ val: 'single', color: 'auto' }); - expect(b?.left).toMatchObject({ val: 'single', color: 'auto' }); - }); - - it('parses different background colors per row and cell', () => { - const cells = pasteTableCells(` - - - - - - - - - -
R0C0R0C1
R1C0R1C1 plain
- `); - - expect(cells).toHaveLength(4); - expect(cells[0].attrs?.background).toEqual({ color: '#ffff00' }); - expect(cells[1].attrs?.background).toEqual({ color: '#00ffff' }); - expect(cells[2].attrs?.background).toEqual({ color: '#800080' }); - expect(cells[3].attrs?.background == null).toBe(true); - }); - - it('parses vertical-align from td style', () => { - const cells = pasteTableCells(` - - - - -
MBT
- `); - - expect(cells).toHaveLength(3); - expect(cells[0].attrs?.verticalAlign).toBe('center'); - expect(cells[1].attrs?.verticalAlign).toBe('bottom'); - expect(cells[2].attrs?.verticalAlign == null).toBe(true); - }); - - it('parses padding into cellMargins on pasted cells', () => { - const cells = pasteTableCells(` - - -
P
- `); - - expect(cells[0].attrs?.cellMargins).toEqual({ - top: 7, - right: 7, - bottom: 7, - left: 7, - }); - }); -}); diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js index c23e5452e1..8071ebbada 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js @@ -40,8 +40,9 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; import { cssColorToHex } from '@core/utilities/cssColorToHex.js'; -import { parseCellBorders } from './helpers/parseCellBorders.js'; -import { parseCellMargins } from './helpers/parseCellMargins.js'; +import { parseCellBorders } from '@extensions/shared/parseCellBorders.js'; +import { parseCellMargins } from '@extensions/shared/parseCellMargins.js'; +import { parseCellVerticalAlignFromStyle } from '@extensions/shared/parseCellVerticalAlign.js'; import { renderCellBorderStyle } from './helpers/renderCellBorderStyle.js'; /** @@ -172,13 +173,7 @@ export const TableCell = Node.create({ }, verticalAlign: { - parseDOM: (element) => { - const value = element.style?.verticalAlign; - if (!value || typeof value !== 'string') return null; - const normalized = value.trim().toLowerCase(); - if (normalized === 'middle') return 'center'; - return normalized; - }, + parseDOM: parseCellVerticalAlignFromStyle, renderDOM({ verticalAlign }) { if (!verticalAlign) return {}; const style = `vertical-align: ${verticalAlign}`; diff --git a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js index 67bc305ea4..8af7c1907f 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js +++ b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js @@ -2,6 +2,10 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; +import { cssColorToHex } from '@core/utilities/cssColorToHex.js'; +import { parseCellBorders } from '@extensions/shared/parseCellBorders.js'; +import { parseCellMargins } from '@extensions/shared/parseCellMargins.js'; +import { parseCellVerticalAlignFromStyle } from '@extensions/shared/parseCellVerticalAlign.js'; import { renderCellBorderStyle } from '../table-cell/helpers/renderCellBorderStyle.js'; /** @@ -87,6 +91,11 @@ export const TableHeader = Node.create({ }, background: { + parseDOM: (element) => { + const color = cssColorToHex(element.style?.backgroundColor); + if (!color) return null; + return { color }; + }, renderDOM({ background }) { if (!background) return {}; // @ts-expect-error - background is known to be an object at runtime @@ -97,6 +106,7 @@ export const TableHeader = Node.create({ }, verticalAlign: { + parseDOM: parseCellVerticalAlignFromStyle, renderDOM({ verticalAlign }) { if (!verticalAlign) return {}; const style = `vertical-align: ${verticalAlign}`; @@ -105,6 +115,7 @@ export const TableHeader = Node.create({ }, cellMargins: { + parseDOM: (element) => parseCellMargins(element), renderDOM({ cellMargins, borders }) { if (!cellMargins) return {}; const sides = ['top', 'right', 'bottom', 'left']; @@ -112,6 +123,7 @@ export const TableHeader = Node.create({ .map((side) => { const margin = cellMargins?.[side] ?? 0; const border = borders?.[side]; + // TODO: this should include table-level borders as well for the first/last cell in the row const borderSize = border && border.val !== 'none' ? Math.ceil(border.size) : 0; if (margin) return `padding-${side}: ${Math.max(0, margin - borderSize)}px;`; @@ -124,6 +136,7 @@ export const TableHeader = Node.create({ borders: { default: null, + parseDOM: (element) => parseCellBorders(element), renderDOM: ({ borders }) => { if (!borders) return {}; return renderCellBorderStyle(borders); diff --git a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js new file mode 100644 index 0000000000..901d263722 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeAll } from 'vitest'; + +import { TableHeader } from './table-header.js'; + +/** + * `tableHeader` uses the same DOM attribute specs as `tableCell` for presentation; + * parse rules run on `` (e.g. Google Docs header rows). + */ +describe('TableHeader', () => { + let attributes; + + beforeAll(() => { + attributes = TableHeader.config.addAttributes.call(TableHeader); + }); + + describe('background', () => { + describe('parseDOM', () => { + it('parses rgb() background to hex', () => { + const th = document.createElement('th'); + th.style.backgroundColor = 'rgb(255, 255, 0)'; + expect(attributes.background.parseDOM(th)).toEqual({ color: '#ffff00' }); + }); + + it('returns null when background is unset', () => { + expect(attributes.background.parseDOM(document.createElement('th'))).toBeNull(); + }); + }); + + describe('renderDOM', () => { + it('omits style when background is absent', () => { + expect(attributes.background.renderDOM({})).toEqual({}); + expect(attributes.background.renderDOM({ background: null })).toEqual({}); + }); + + it('renders background-color for hex digits without leading #', () => { + expect(attributes.background.renderDOM({ background: { color: 'ffff00' } })).toEqual({ + style: 'background-color: #ffff00', + }); + }); + + it('uses transparent when color is missing on background object', () => { + expect(attributes.background.renderDOM({ background: {} })).toEqual({ + style: 'background-color: transparent', + }); + }); + }); + }); + + describe('verticalAlign', () => { + describe('parseDOM', () => { + it('maps middle to center', () => { + const th = document.createElement('th'); + th.style.verticalAlign = 'middle'; + expect(attributes.verticalAlign.parseDOM(th)).toBe('center'); + }); + + it('normalizes top and bottom', () => { + const topTh = document.createElement('th'); + topTh.style.verticalAlign = 'TOP'; + expect(attributes.verticalAlign.parseDOM(topTh)).toBe('top'); + + const bottomTh = document.createElement('th'); + bottomTh.style.verticalAlign = 'bottom'; + expect(attributes.verticalAlign.parseDOM(bottomTh)).toBe('bottom'); + }); + + it('returns null when vertical-align is missing', () => { + expect(attributes.verticalAlign.parseDOM(document.createElement('th'))).toBeNull(); + }); + }); + + describe('renderDOM', () => { + it('omits style when verticalAlign is unset', () => { + expect(attributes.verticalAlign.renderDOM({})).toEqual({}); + expect(attributes.verticalAlign.renderDOM({ verticalAlign: null })).toEqual({}); + }); + + it('emits vertical-align CSS', () => { + expect(attributes.verticalAlign.renderDOM({ verticalAlign: 'bottom' })).toEqual({ + style: 'vertical-align: bottom', + }); + }); + }); + }); + + describe('cellMargins', () => { + describe('parseDOM', () => { + it('parses uniform padding in pt', () => { + const th = document.createElement('th'); + th.style.padding = '5pt'; + expect(attributes.cellMargins.parseDOM(th)).toEqual({ + top: 7, + right: 7, + bottom: 7, + left: 7, + }); + }); + + it('parses per-side padding', () => { + const th = document.createElement('th'); + th.style.paddingTop = '5pt'; + th.style.paddingRight = '10pt'; + th.style.paddingBottom = '15pt'; + expect(attributes.cellMargins.parseDOM(th)).toEqual({ + top: 7, + right: 13, + bottom: 20, + }); + }); + + it('returns null when no padding is set', () => { + expect(attributes.cellMargins.parseDOM(document.createElement('th'))).toBeNull(); + }); + }); + + describe('renderDOM', () => { + it('returns {} when cellMargins is absent', () => { + expect(attributes.cellMargins.renderDOM({})).toEqual({}); + }); + + it('subtracts non-none border width from padding', () => { + const result = attributes.cellMargins.renderDOM({ + cellMargins: { top: 12, right: 8 }, + borders: { + top: { val: 'single', size: 3, color: '#000', style: 'solid' }, + right: { val: 'none', size: 0, color: 'auto', style: 'none' }, + }, + }); + expect(result.style).toContain('padding-top: 9px'); + expect(result.style).toContain('padding-right: 8px'); + }); + }); + }); + + describe('borders', () => { + describe('parseDOM', () => { + it('applies shorthand border to all sides', () => { + const th = document.createElement('th'); + th.style.border = '1px solid rgb(0, 0, 0)'; + expect(attributes.borders.parseDOM(th)).toEqual({ + top: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + right: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + bottom: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + left: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + }); + }); + + it('uses per-side rules when they override shorthand', () => { + const th = document.createElement('th'); + th.style.border = '1px solid rgb(0, 0, 0)'; + th.style.borderTop = '2px dashed rgb(255, 0, 0)'; + th.style.borderRight = '3px dotted rgb(0, 0, 255)'; + th.style.borderBottom = '4px double rgb(0, 128, 0)'; + expect(attributes.borders.parseDOM(th)).toEqual({ + top: { val: 'dashed', size: 2, color: '#ff0000', style: 'dashed' }, + right: { val: 'dotted', size: 3, color: '#0000ff', style: 'dotted' }, + bottom: { val: 'single', size: 4, color: '#008000', style: 'double' }, + left: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + }); + }); + + it('returns null when no border is set', () => { + expect(attributes.borders.parseDOM(document.createElement('th'))).toBeNull(); + }); + }); + + describe('renderDOM', () => { + it('returns {} when borders are absent', () => { + expect(attributes.borders.renderDOM({})).toEqual({}); + }); + + it('delegates to renderCellBorderStyle (solid px, auto → black)', () => { + const result = attributes.borders.renderDOM({ + borders: { + top: { val: 'single', size: 1, color: 'auto', style: 'solid' }, + }, + }); + expect(result.style).toContain('border-top: 1px solid black'); + }); + + it('renders border none without width', () => { + const result = attributes.borders.renderDOM({ + borders: { + top: { val: 'none', size: 0, color: 'auto', style: 'none' }, + }, + }); + expect(result.style).toContain('border-top: none'); + }); + }); + }); +}); From 627103622d5008b741f8d658096788adaa0c4d1e Mon Sep 17 00:00:00 2001 From: Rafael Azevedo Date: Tue, 7 Apr 2026 21:31:50 -0300 Subject: [PATCH 15/16] feat(table): move cell border rendering logic to shared --- .../{table-cell/helpers => shared}/renderCellBorderStyle.js | 2 +- .../src/editors/v1/extensions/table-cell/table-cell.js | 2 +- .../src/editors/v1/extensions/table-header/table-header.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename packages/super-editor/src/editors/v1/extensions/{table-cell/helpers => shared}/renderCellBorderStyle.js (88%) diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/renderCellBorderStyle.js b/packages/super-editor/src/editors/v1/extensions/shared/renderCellBorderStyle.js similarity index 88% rename from packages/super-editor/src/editors/v1/extensions/table-cell/helpers/renderCellBorderStyle.js rename to packages/super-editor/src/editors/v1/extensions/shared/renderCellBorderStyle.js index f55bf32b7f..cd1e7eeff0 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/renderCellBorderStyle.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/renderCellBorderStyle.js @@ -6,7 +6,7 @@ * Shared by both `tableCell` and `tableHeader` node `renderDOM` methods * so the border-rendering logic stays in one place. * - * @param {import('./createCellBorders.js').CellBorders | null | undefined} borders + * @param {import('../table-cell/helpers/createCellBorders.js').CellBorders | null | undefined} borders * @returns {{ style: string } | {}} */ export const renderCellBorderStyle = (borders) => { diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js index 8071ebbada..ed8a6cb44e 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js @@ -43,7 +43,7 @@ import { cssColorToHex } from '@core/utilities/cssColorToHex.js'; import { parseCellBorders } from '@extensions/shared/parseCellBorders.js'; import { parseCellMargins } from '@extensions/shared/parseCellMargins.js'; import { parseCellVerticalAlignFromStyle } from '@extensions/shared/parseCellVerticalAlign.js'; -import { renderCellBorderStyle } from './helpers/renderCellBorderStyle.js'; +import { renderCellBorderStyle } from '@extensions/shared/renderCellBorderStyle.js'; /** * Cell margins configuration diff --git a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js index 8af7c1907f..1270a550bc 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js +++ b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js @@ -6,7 +6,7 @@ import { cssColorToHex } from '@core/utilities/cssColorToHex.js'; import { parseCellBorders } from '@extensions/shared/parseCellBorders.js'; import { parseCellMargins } from '@extensions/shared/parseCellMargins.js'; import { parseCellVerticalAlignFromStyle } from '@extensions/shared/parseCellVerticalAlign.js'; -import { renderCellBorderStyle } from '../table-cell/helpers/renderCellBorderStyle.js'; +import { renderCellBorderStyle } from '@extensions/shared/renderCellBorderStyle.js'; /** * Configuration options for TableHeader From 6b14e6d8c4f007669a5bb7c66d3a9005f2ef688d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 7 Apr 2026 18:14:50 -0700 Subject: [PATCH 16/16] fix(table): strip # prefix from pasted background colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cssColorToHex returns values with the # prefix (e.g. #ffff00) but the rest of the system stores background colors as bare hex. renderDOM prepends # and the OOXML exporter writes background.color directly into shading.fill, so the mismatch produced ##ffff00 in CSS and #ffff00 in OOXML — both invalid. Also adds parseDOM → renderDOM round-trip tests to catch this class of format mismatch in the future. --- .../table-cell-header-paste.integration.test.js | 16 ++++++++-------- .../v1/extensions/table-cell/table-cell.js | 2 +- .../v1/extensions/table-cell/table-cell.test.js | 10 +++++++++- .../v1/extensions/table-header/table-header.js | 2 +- .../extensions/table-header/table-header.test.js | 10 +++++++++- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js b/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js index 262b37cb3d..b884ec1612 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js @@ -124,9 +124,9 @@ describe('tableCell & tableHeader HTML paste integration', () => { `); expect(cells).toHaveLength(4); - expect(cells[0].attrs?.background).toEqual({ color: '#ffff00' }); - expect(cells[1].attrs?.background).toEqual({ color: '#00ffff' }); - expect(cells[2].attrs?.background).toEqual({ color: '#800080' }); + expect(cells[0].attrs?.background).toEqual({ color: 'ffff00' }); + expect(cells[1].attrs?.background).toEqual({ color: '00ffff' }); + expect(cells[2].attrs?.background).toEqual({ color: '800080' }); expect(cells[3].attrs?.background == null).toBe(true); }); @@ -185,7 +185,7 @@ describe('tableCell & tableHeader HTML paste integration', () => { bottom: 7, left: 7, }); - expect(cells[0].attrs?.background).toEqual({ color: '#c8dcff' }); + expect(cells[0].attrs?.background).toEqual({ color: 'c8dcff' }); expect(cells[0].attrs?.verticalAlign).toBe('bottom'); const hb = cells[0].attrs?.tableCellProperties?.borders; @@ -230,7 +230,7 @@ describe('tableCell & tableHeader HTML paste integration', () => { 'tableCell', ]); - expect(cells[0].attrs?.background).toEqual({ color: '#e6e6e6' }); + expect(cells[0].attrs?.background).toEqual({ color: 'e6e6e6' }); expect(cells[1].attrs?.cellMargins).toEqual({ top: 6, right: 6, @@ -262,7 +262,7 @@ describe('tableCell & tableHeader HTML paste integration', () => { expect(hBorder?.top).toMatchObject({ val: 'single', color: '#c80000' }); expect(cells[1].attrs?.cellMargins?.top).toBe(7); - expect(cells[1].attrs?.background).toEqual({ color: '#00c8c8' }); + expect(cells[1].attrs?.background).toEqual({ color: '00c8c8' }); expect(cells[2].attrs?.cellMargins).toEqual({ top: 2, @@ -296,7 +296,7 @@ describe('tableCell & tableHeader HTML paste integration', () => { expect(cells).toHaveLength(7); expect(cells[0].type).toBe('tableHeader'); expect(cells[1].type).toBe('tableHeader'); - expect(cells[0].attrs?.background).toEqual({ color: '#6464c8' }); + expect(cells[0].attrs?.background).toEqual({ color: '6464c8' }); expect(cells[1].attrs?.background == null).toBe(true); expect(cells[2].type).toBe('tableHeader'); @@ -338,7 +338,7 @@ describe('tableCell & tableHeader HTML paste integration', () => { expect(cells[3].type).toBe('tableHeader'); expect(cells[4].type).toBe('tableCell'); - expect(cells[4].attrs?.background).toEqual({ color: '#fff0f0' }); + expect(cells[4].attrs?.background).toEqual({ color: 'fff0f0' }); expect(cells[5].type).toBe('tableCell'); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js index ed8a6cb44e..3999d7ea40 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js @@ -161,7 +161,7 @@ export const TableCell = Node.create({ parseDOM: (element) => { const color = cssColorToHex(element.style?.backgroundColor); if (!color) return null; - return { color }; + return { color: color.replace(/^#/, '') }; }, renderDOM({ background }) { if (!background) return {}; diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js index e698981aa4..33a01e6c2d 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js @@ -14,7 +14,7 @@ describe('TableCell', () => { it('parses rgb() background to hex', () => { const td = document.createElement('td'); td.style.backgroundColor = 'rgb(255, 255, 0)'; - expect(attributes.background.parseDOM(td)).toEqual({ color: '#ffff00' }); + expect(attributes.background.parseDOM(td)).toEqual({ color: 'ffff00' }); }); it('returns null when background is unset', () => { @@ -41,6 +41,14 @@ describe('TableCell', () => { }); }); }); + + it('parseDOM → renderDOM round-trips to valid CSS', () => { + const td = document.createElement('td'); + td.style.backgroundColor = 'rgb(255, 255, 0)'; + const parsed = attributes.background.parseDOM(td); + const { style } = attributes.background.renderDOM({ background: parsed }); + expect(style).toBe('background-color: #ffff00'); + }); }); describe('verticalAlign', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js index 1270a550bc..329134e110 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js +++ b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js @@ -94,7 +94,7 @@ export const TableHeader = Node.create({ parseDOM: (element) => { const color = cssColorToHex(element.style?.backgroundColor); if (!color) return null; - return { color }; + return { color: color.replace(/^#/, '') }; }, renderDOM({ background }) { if (!background) return {}; diff --git a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js index 901d263722..73b5f8dce7 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js @@ -18,7 +18,7 @@ describe('TableHeader', () => { it('parses rgb() background to hex', () => { const th = document.createElement('th'); th.style.backgroundColor = 'rgb(255, 255, 0)'; - expect(attributes.background.parseDOM(th)).toEqual({ color: '#ffff00' }); + expect(attributes.background.parseDOM(th)).toEqual({ color: 'ffff00' }); }); it('returns null when background is unset', () => { @@ -44,6 +44,14 @@ describe('TableHeader', () => { }); }); }); + + it('parseDOM → renderDOM round-trips to valid CSS', () => { + const th = document.createElement('th'); + th.style.backgroundColor = 'rgb(255, 255, 0)'; + const parsed = attributes.background.parseDOM(th); + const { style } = attributes.background.renderDOM({ background: parsed }); + expect(style).toBe('background-color: #ffff00'); + }); }); describe('verticalAlign', () => {