From c8b6e520863c674ce44f89527e4dd3853e02f9fb Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Sat, 28 Feb 2026 00:20:14 +0530 Subject: [PATCH 1/5] fix: preserve text-align on paste from Google Docs Add CSS text-align fallback in parseAttrs to extract justification from inline styles (e.g. `

`), following the same pattern as the existing spacing/indent CSS fallbacks from PR #2183. Maps CSS values (left, center, right, justify, start, end) to OOXML justification values. --- .../paragraph/helpers/parseAttrs.js | 21 +++++++ .../paragraph/helpers/parseAttrs.test.js | 58 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js index 94de991a71..edf0d48167 100644 --- a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js +++ b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js @@ -104,6 +104,23 @@ export function parseAttrs(node) { } } + // CSS inline style fallback for text-align (e.g. Google Docs paste) + let justification; + if (node.style) { + const textAlign = node.style.textAlign; + const alignMap = { + left: 'left', + center: 'center', + right: 'right', + justify: 'justify', + start: 'left', + end: 'right', + }; + if (textAlign && alignMap[textAlign]) { + justification = alignMap[textAlign]; + } + } + let attrs = { paragraphProperties: { styleId: styleId || null, @@ -119,6 +136,10 @@ export function parseAttrs(node) { attrs.paragraphProperties.spacing = spacing; } + if (justification) { + attrs.paragraphProperties.justification = justification; + } + if (Object.keys(numberingProperties).length > 0) { attrs.paragraphProperties.numberingProperties = numberingProperties; } diff --git a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js index 28c83c0727..f2d844c1a6 100644 --- a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js +++ b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js @@ -94,7 +94,7 @@ describe('parseAttrs', () => { const node = createMockNode({}, { marginTop: '16px' }); const result = parseAttrs(node); // 16px / 1.333 = ~12pt, * 20 = ~240 twips - const expectedPt = 16 * 72 / 96; + const expectedPt = (16 * 72) / 96; expect(result.paragraphProperties.spacing.before).toBe(Math.round(expectedPt * 20)); }); @@ -108,7 +108,7 @@ describe('parseAttrs', () => { it('extracts marginLeft in px and converts to twips', () => { const node = createMockNode({}, { marginLeft: '48px' }); const result = parseAttrs(node); - const expectedPt = 48 * 72 / 96; + const expectedPt = (48 * 72) / 96; expect(result.paragraphProperties.indent.left).toBe(Math.round(expectedPt * 20)); }); @@ -133,7 +133,7 @@ describe('parseAttrs', () => { const node = createMockNode({}, { lineHeight: '24px' }); const result = parseAttrs(node); // 24px / 1.333 ≈ 18pt, * 20 = 360 twips - expect(result.paragraphProperties.spacing.line).toBe(Math.round((24 * 72 / 96) * 20)); + expect(result.paragraphProperties.spacing.line).toBe(Math.round(((24 * 72) / 96) * 20)); expect(result.paragraphProperties.spacing.lineRule).toBe('exact'); }); @@ -234,4 +234,56 @@ describe('parseAttrs', () => { expect(result.paragraphProperties.indent).toBeUndefined(); }); }); + + describe('CSS text-align fallback (Google Docs paste)', () => { + it('extracts text-align: center as justification', () => { + const node = createMockNode({}, { textAlign: 'center' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBe('center'); + }); + + it('extracts text-align: right as justification', () => { + const node = createMockNode({}, { textAlign: 'right' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBe('right'); + }); + + it('extracts text-align: justify as justification', () => { + const node = createMockNode({}, { textAlign: 'justify' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBe('justify'); + }); + + it('extracts text-align: left as justification', () => { + const node = createMockNode({}, { textAlign: 'left' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBe('left'); + }); + + it('maps text-align: start to justification left', () => { + const node = createMockNode({}, { textAlign: 'start' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBe('left'); + }); + + it('maps text-align: end to justification right', () => { + const node = createMockNode({}, { textAlign: 'end' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBe('right'); + }); + + it('ignores invalid text-align values', () => { + const node = createMockNode({}, { textAlign: 'middle' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBeUndefined(); + }); + + it('combines text-align with spacing and indent CSS fallbacks', () => { + const node = createMockNode({}, { textAlign: 'center', lineHeight: '1.5', marginLeft: '36pt' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBe('center'); + expect(result.paragraphProperties.spacing.line).toBe(Math.round((1.5 * 240) / 1.15)); + expect(result.paragraphProperties.indent.left).toBe(720); + }); + }); }); From 1f1e46f1e2dea4104515c8f07855e669bd5c96f1 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Sat, 28 Feb 2026 20:27:22 +0530 Subject: [PATCH 2/5] Update packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> --- .../src/extensions/paragraph/helpers/parseAttrs.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js index edf0d48167..fa88c003b0 100644 --- a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js +++ b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js @@ -106,7 +106,8 @@ export function parseAttrs(node) { // CSS inline style fallback for text-align (e.g. Google Docs paste) let justification; - if (node.style) { + let justification; + if (!justification && node.style) { const textAlign = node.style.textAlign; const alignMap = { left: 'left', From b239dff3eec12239c3b8ab1b0bd65b63c40966e6 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Sat, 28 Feb 2026 20:32:26 +0530 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20skip?= =?UTF-8?q?=20default=20left=20alignment,=20fix=20duplicate=20declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicate `let justification` introduced by GitHub suggestion merge. Skip `left` and `start` text-align values since Google Docs sets text-align: left on every paragraph — storing it bakes in unnecessary direct formatting (``) on export. --- .../src/extensions/paragraph/helpers/parseAttrs.js | 5 ++--- .../src/extensions/paragraph/helpers/parseAttrs.test.js | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js index fa88c003b0..e5311b452c 100644 --- a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js +++ b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js @@ -105,16 +105,15 @@ export function parseAttrs(node) { } // CSS inline style fallback for text-align (e.g. Google Docs paste) - let justification; + // Skip 'left' — Google Docs sets text-align: left on every paragraph, + // and storing it would bake in unnecessary direct formatting on export. let justification; if (!justification && node.style) { const textAlign = node.style.textAlign; const alignMap = { - left: 'left', center: 'center', right: 'right', justify: 'justify', - start: 'left', end: 'right', }; if (textAlign && alignMap[textAlign]) { diff --git a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js index f2d844c1a6..8bb7b3eab1 100644 --- a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js +++ b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js @@ -254,16 +254,16 @@ describe('parseAttrs', () => { expect(result.paragraphProperties.justification).toBe('justify'); }); - it('extracts text-align: left as justification', () => { + it('skips text-align: left (default, avoids unnecessary direct formatting)', () => { const node = createMockNode({}, { textAlign: 'left' }); const result = parseAttrs(node); - expect(result.paragraphProperties.justification).toBe('left'); + expect(result.paragraphProperties.justification).toBeUndefined(); }); - it('maps text-align: start to justification left', () => { + it('skips text-align: start (maps to left, which is default)', () => { const node = createMockNode({}, { textAlign: 'start' }); const result = parseAttrs(node); - expect(result.paragraphProperties.justification).toBe('left'); + expect(result.paragraphProperties.justification).toBeUndefined(); }); it('maps text-align: end to justification right', () => { From 48d95b99e50cd892923cbf857aeabb5fb7905e4f Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Sun, 1 Mar 2026 18:04:14 +0530 Subject: [PATCH 4/5] test: add behavior tests for text-align preservation through paste pipeline --- .../tests/formatting/paste-text-align.spec.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/behavior/tests/formatting/paste-text-align.spec.ts diff --git a/tests/behavior/tests/formatting/paste-text-align.spec.ts b/tests/behavior/tests/formatting/paste-text-align.spec.ts new file mode 100644 index 0000000000..15bc9886b3 --- /dev/null +++ b/tests/behavior/tests/formatting/paste-text-align.spec.ts @@ -0,0 +1,43 @@ +import { test } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +/** + * Insert HTML via editor.commands.insertContent to simulate the paste path + * (HTML → parseDOM → parseAttrs → document model). + */ +async function insertHTML(page: import('@playwright/test').Page, html: string) { + await page.evaluate((h) => { + const editor = (window as any).editor; + editor.commands.insertContent(h); + }, html); +} + +test('pasted center-aligned paragraph preserves alignment', async ({ superdoc }) => { + await insertHTML(superdoc.page, '

Centered text

'); + await superdoc.waitForStable(); + + await superdoc.assertTextAlignment('Centered text', 'center'); +}); + +test('pasted right-aligned paragraph preserves alignment', async ({ superdoc }) => { + await insertHTML(superdoc.page, '

Right text

'); + await superdoc.waitForStable(); + + await superdoc.assertTextAlignment('Right text', 'right'); +}); + +test('pasted justified paragraph preserves alignment', async ({ superdoc }) => { + await insertHTML(superdoc.page, '

Justified text

'); + await superdoc.waitForStable(); + + await superdoc.assertTextAlignment('Justified text', 'justify'); +}); + +test('pasted left-aligned paragraph does not store alignment (default)', async ({ superdoc }) => { + await insertHTML(superdoc.page, '

Left text

'); + await superdoc.waitForStable(); + + // left is the default — parseAttrs skips it to avoid baking in direct formatting + await superdoc.assertTextAlignment('Left text', null as any); +}); From ae924c64be96078a6d35f8198f844dc1bff4e257 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Mon, 2 Mar 2026 12:18:17 +0530 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20hoist=20alignMap,=20widen=20assertTextAlignment=20t?= =?UTF-8?q?ype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/extensions/paragraph/helpers/parseAttrs.js | 11 +++-------- tests/behavior/fixtures/superdoc.ts | 2 +- .../tests/formatting/paste-text-align.spec.ts | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js index e5311b452c..d09444685b 100644 --- a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js +++ b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js @@ -1,4 +1,5 @@ const CSS_LENGTH_TO_PT = { pt: 1, px: 72 / 96, in: 72, cm: 28.3465, mm: 2.83465 }; +const CSS_ALIGN_TO_OOXML = { center: 'center', right: 'right', justify: 'justify', end: 'right' }; /** * Parse a CSS length value and return { points, unit }. @@ -110,14 +111,8 @@ export function parseAttrs(node) { let justification; if (!justification && node.style) { const textAlign = node.style.textAlign; - const alignMap = { - center: 'center', - right: 'right', - justify: 'justify', - end: 'right', - }; - if (textAlign && alignMap[textAlign]) { - justification = alignMap[textAlign]; + if (textAlign && CSS_ALIGN_TO_OOXML[textAlign]) { + justification = CSS_ALIGN_TO_OOXML[textAlign]; } } diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index 381019d341..e37d880ccc 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -780,7 +780,7 @@ function createFixture(page: Page, editor: Locator, modKey: string) { throw new Error(`assertTextMarkAttrs only supports "link" and "textStyle" via document-api; got "${markName}".`); }, - async assertTextAlignment(text: string, expectedAlignment: string, occurrence = 0) { + async assertTextAlignment(text: string, expectedAlignment: string | null, occurrence = 0) { await expect .poll(() => page.evaluate( diff --git a/tests/behavior/tests/formatting/paste-text-align.spec.ts b/tests/behavior/tests/formatting/paste-text-align.spec.ts index 15bc9886b3..9c1f6cd79e 100644 --- a/tests/behavior/tests/formatting/paste-text-align.spec.ts +++ b/tests/behavior/tests/formatting/paste-text-align.spec.ts @@ -39,5 +39,5 @@ test('pasted left-aligned paragraph does not store alignment (default)', async ( await superdoc.waitForStable(); // left is the default — parseAttrs skips it to avoid baking in direct formatting - await superdoc.assertTextAlignment('Left text', null as any); + await superdoc.assertTextAlignment('Left text', null); });