diff --git a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js index 94de991a71..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 }. @@ -104,6 +105,17 @@ export function parseAttrs(node) { } } + // CSS inline style fallback for text-align (e.g. Google Docs paste) + // 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; + if (textAlign && CSS_ALIGN_TO_OOXML[textAlign]) { + justification = CSS_ALIGN_TO_OOXML[textAlign]; + } + } + let attrs = { paragraphProperties: { styleId: styleId || null, @@ -119,6 +131,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..8bb7b3eab1 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('skips text-align: left (default, avoids unnecessary direct formatting)', () => { + const node = createMockNode({}, { textAlign: 'left' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBeUndefined(); + }); + + it('skips text-align: start (maps to left, which is default)', () => { + const node = createMockNode({}, { textAlign: 'start' }); + const result = parseAttrs(node); + expect(result.paragraphProperties.justification).toBeUndefined(); + }); + + 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); + }); + }); }); 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 new file mode 100644 index 0000000000..9c1f6cd79e --- /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); +});