Skip to content

Commit 762231b

Browse files
fix: preserve text-align on paste from Google Docs (#2208)
* fix: preserve text-align on paste from Google Docs Add CSS text-align fallback in parseAttrs to extract justification from inline styles (e.g. `<p style="text-align: center">`), 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. * Update packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> * fix: address review — skip default left alignment, fix duplicate declaration 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 (`<w:jc w:val="left"/>`) on export. * test: add behavior tests for text-align preservation through paste pipeline * fix: address review feedback — hoist alignMap, widen assertTextAlignment type --------- Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
1 parent e90ddf9 commit 762231b

4 files changed

Lines changed: 112 additions & 1 deletion

File tree

packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const CSS_LENGTH_TO_PT = { pt: 1, px: 72 / 96, in: 72, cm: 28.3465, mm: 2.83465 };
2+
const CSS_ALIGN_TO_OOXML = { center: 'center', right: 'right', justify: 'justify', end: 'right' };
23

34
/**
45
* Parse a CSS length value and return { points, unit }.
@@ -104,6 +105,17 @@ export function parseAttrs(node) {
104105
}
105106
}
106107

108+
// CSS inline style fallback for text-align (e.g. Google Docs paste)
109+
// Skip 'left' — Google Docs sets text-align: left on every paragraph,
110+
// and storing it would bake in unnecessary direct formatting on export.
111+
let justification;
112+
if (!justification && node.style) {
113+
const textAlign = node.style.textAlign;
114+
if (textAlign && CSS_ALIGN_TO_OOXML[textAlign]) {
115+
justification = CSS_ALIGN_TO_OOXML[textAlign];
116+
}
117+
}
118+
107119
let attrs = {
108120
paragraphProperties: {
109121
styleId: styleId || null,
@@ -119,6 +131,10 @@ export function parseAttrs(node) {
119131
attrs.paragraphProperties.spacing = spacing;
120132
}
121133

134+
if (justification) {
135+
attrs.paragraphProperties.justification = justification;
136+
}
137+
122138
if (Object.keys(numberingProperties).length > 0) {
123139
attrs.paragraphProperties.numberingProperties = numberingProperties;
124140
}

packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,56 @@ describe('parseAttrs', () => {
234234
expect(result.paragraphProperties.indent).toBeUndefined();
235235
});
236236
});
237+
238+
describe('CSS text-align fallback (Google Docs paste)', () => {
239+
it('extracts text-align: center as justification', () => {
240+
const node = createMockNode({}, { textAlign: 'center' });
241+
const result = parseAttrs(node);
242+
expect(result.paragraphProperties.justification).toBe('center');
243+
});
244+
245+
it('extracts text-align: right as justification', () => {
246+
const node = createMockNode({}, { textAlign: 'right' });
247+
const result = parseAttrs(node);
248+
expect(result.paragraphProperties.justification).toBe('right');
249+
});
250+
251+
it('extracts text-align: justify as justification', () => {
252+
const node = createMockNode({}, { textAlign: 'justify' });
253+
const result = parseAttrs(node);
254+
expect(result.paragraphProperties.justification).toBe('justify');
255+
});
256+
257+
it('skips text-align: left (default, avoids unnecessary direct formatting)', () => {
258+
const node = createMockNode({}, { textAlign: 'left' });
259+
const result = parseAttrs(node);
260+
expect(result.paragraphProperties.justification).toBeUndefined();
261+
});
262+
263+
it('skips text-align: start (maps to left, which is default)', () => {
264+
const node = createMockNode({}, { textAlign: 'start' });
265+
const result = parseAttrs(node);
266+
expect(result.paragraphProperties.justification).toBeUndefined();
267+
});
268+
269+
it('maps text-align: end to justification right', () => {
270+
const node = createMockNode({}, { textAlign: 'end' });
271+
const result = parseAttrs(node);
272+
expect(result.paragraphProperties.justification).toBe('right');
273+
});
274+
275+
it('ignores invalid text-align values', () => {
276+
const node = createMockNode({}, { textAlign: 'middle' });
277+
const result = parseAttrs(node);
278+
expect(result.paragraphProperties.justification).toBeUndefined();
279+
});
280+
281+
it('combines text-align with spacing and indent CSS fallbacks', () => {
282+
const node = createMockNode({}, { textAlign: 'center', lineHeight: '1.5', marginLeft: '36pt' });
283+
const result = parseAttrs(node);
284+
expect(result.paragraphProperties.justification).toBe('center');
285+
expect(result.paragraphProperties.spacing.line).toBe(Math.round((1.5 * 240) / 1.15));
286+
expect(result.paragraphProperties.indent.left).toBe(720);
287+
});
288+
});
237289
});

tests/behavior/fixtures/superdoc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -780,7 +780,7 @@ function createFixture(page: Page, editor: Locator, modKey: string) {
780780
throw new Error(`assertTextMarkAttrs only supports "link" and "textStyle" via document-api; got "${markName}".`);
781781
},
782782

783-
async assertTextAlignment(text: string, expectedAlignment: string, occurrence = 0) {
783+
async assertTextAlignment(text: string, expectedAlignment: string | null, occurrence = 0) {
784784
await expect
785785
.poll(() =>
786786
page.evaluate(
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { test } from '../../fixtures/superdoc.js';
2+
3+
test.use({ config: { toolbar: 'full', showSelection: true } });
4+
5+
/**
6+
* Insert HTML via editor.commands.insertContent to simulate the paste path
7+
* (HTML → parseDOM → parseAttrs → document model).
8+
*/
9+
async function insertHTML(page: import('@playwright/test').Page, html: string) {
10+
await page.evaluate((h) => {
11+
const editor = (window as any).editor;
12+
editor.commands.insertContent(h);
13+
}, html);
14+
}
15+
16+
test('pasted center-aligned paragraph preserves alignment', async ({ superdoc }) => {
17+
await insertHTML(superdoc.page, '<p style="text-align: center">Centered text</p>');
18+
await superdoc.waitForStable();
19+
20+
await superdoc.assertTextAlignment('Centered text', 'center');
21+
});
22+
23+
test('pasted right-aligned paragraph preserves alignment', async ({ superdoc }) => {
24+
await insertHTML(superdoc.page, '<p style="text-align: right">Right text</p>');
25+
await superdoc.waitForStable();
26+
27+
await superdoc.assertTextAlignment('Right text', 'right');
28+
});
29+
30+
test('pasted justified paragraph preserves alignment', async ({ superdoc }) => {
31+
await insertHTML(superdoc.page, '<p style="text-align: justify">Justified text</p>');
32+
await superdoc.waitForStable();
33+
34+
await superdoc.assertTextAlignment('Justified text', 'justify');
35+
});
36+
37+
test('pasted left-aligned paragraph does not store alignment (default)', async ({ superdoc }) => {
38+
await insertHTML(superdoc.page, '<p style="text-align: left">Left text</p>');
39+
await superdoc.waitForStable();
40+
41+
// left is the default — parseAttrs skips it to avoid baking in direct formatting
42+
await superdoc.assertTextAlignment('Left text', null);
43+
});

0 commit comments

Comments
 (0)