Skip to content

Commit a626bae

Browse files
committed
fix(paste): improve table pasting
1 parent 070190f commit a626bae

7 files changed

Lines changed: 197 additions & 0 deletions

File tree

packages/super-editor/src/core/InputRule.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,64 @@ function findParagraphAncestor($from) {
264264
return { node: null, depth: -1 };
265265
}
266266

267+
/**
268+
* @param {import('prosemirror-model').Node} tableRow
269+
* @returns {string}
270+
*/
271+
function getTableRowSignature(tableRow) {
272+
const parts = [];
273+
tableRow.forEach((cell) => {
274+
parts.push(`${cell.attrs?.colspan ?? 1}:${cell.attrs?.rowspan ?? 1}`);
275+
});
276+
return parts.join('|');
277+
}
278+
279+
/**
280+
* Browser "highlight copy" can emit table-like HTML where each visual row
281+
* becomes an independent table element. Merge adjacent compatible tables back
282+
* into one table so table editing features (cell selection, resizing) work.
283+
*
284+
* @param {import('prosemirror-model').Node} doc
285+
* @returns {import('prosemirror-model').Node}
286+
*/
287+
function mergeAdjacentTableFragments(doc) {
288+
if (!doc?.childCount) return doc;
289+
290+
/** @type {import('prosemirror-model').Node[]} */
291+
const mergedChildren = [];
292+
293+
doc.forEach((child) => {
294+
const previous = mergedChildren[mergedChildren.length - 1];
295+
296+
if (child.type.name !== 'table' || previous?.type.name !== 'table') {
297+
mergedChildren.push(child);
298+
return;
299+
}
300+
301+
const previousFirstRow = previous.firstChild;
302+
const currentFirstRow = child.firstChild;
303+
if (!previousFirstRow || !currentFirstRow) {
304+
mergedChildren.push(child);
305+
return;
306+
}
307+
308+
const previousColumnShape = getTableRowSignature(previousFirstRow);
309+
const currentColumnShape = getTableRowSignature(currentFirstRow);
310+
if (previousColumnShape !== currentColumnShape) {
311+
mergedChildren.push(child);
312+
return;
313+
}
314+
315+
const combinedRows = [];
316+
previous.forEach((row) => combinedRows.push(row));
317+
child.forEach((row) => combinedRows.push(row));
318+
319+
mergedChildren[mergedChildren.length - 1] = previous.type.create(previous.attrs, combinedRows, previous.marks);
320+
});
321+
322+
return doc.copy(Fragment.fromArray(mergedChildren));
323+
}
324+
267325
/**
268326
* Handle HTML paste events.
269327
*
@@ -277,7 +335,14 @@ export function handleHtmlPaste(html, editor, source) {
277335
if (source === 'google-docs') cleanedHtml = handleGoogleDocsHtml(html, editor);
278336
else cleanedHtml = htmlHandler(html, editor);
279337

338+
// Mark pasted HTML as import content so table parseDOM rules can apply
339+
// import defaults (e.g., default table width to 100%).
340+
if (cleanedHtml?.dataset) {
341+
cleanedHtml.dataset.superdocImport = 'true';
342+
}
343+
280344
let doc = PMDOMParser.fromSchema(editor.schema).parse(cleanedHtml);
345+
doc = mergeAdjacentTableFragments(doc);
281346

282347
doc = wrapTextsInRuns(doc);
283348

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { beforeAll, afterEach, describe, expect, it } from 'vitest';
2+
import { handleClipboardPaste, handleHtmlPaste } from './InputRule.js';
3+
import { initTestEditor, loadTestDataForEditorTests } from '../tests/helpers/helpers.js';
4+
5+
let docData;
6+
let editor;
7+
8+
beforeAll(async () => {
9+
docData = await loadTestDataForEditorTests('blank-doc.docx');
10+
});
11+
12+
afterEach(() => {
13+
editor?.destroy();
14+
editor = null;
15+
});
16+
17+
describe('handleHtmlPaste table import defaults', () => {
18+
it('defaults pasted HTML tables to 100% width', () => {
19+
({ editor } = initTestEditor({
20+
content: docData.docx,
21+
media: docData.media,
22+
mediaFiles: docData.mediaFiles,
23+
fonts: docData.fonts,
24+
mode: 'docx',
25+
}));
26+
27+
const handled = handleHtmlPaste(
28+
'<table><tbody><tr><td>Query</td><td>Assessment</td></tr><tr><td>A</td><td>B</td></tr></tbody></table>',
29+
editor,
30+
);
31+
32+
expect(handled).toBe(true);
33+
34+
const tableNode = (editor.getJSON().content || []).find((node) => node.type === 'table');
35+
expect(tableNode).toBeTruthy();
36+
expect(tableNode?.attrs?.tableProperties?.tableWidth).toEqual({
37+
value: 5000,
38+
type: 'pct',
39+
});
40+
});
41+
42+
it('defaults Google Docs HTML tables to 100% width', () => {
43+
({ editor } = initTestEditor({
44+
content: docData.docx,
45+
media: docData.media,
46+
mediaFiles: docData.mediaFiles,
47+
fonts: docData.fonts,
48+
mode: 'docx',
49+
}));
50+
51+
const handled = handleClipboardPaste(
52+
{ editor, view: editor.view },
53+
'<div docs-internal-guid-test><table><tbody><tr><td>Query</td><td>Assessment</td></tr><tr><td>A</td><td>B</td></tr></tbody></table></div>',
54+
);
55+
56+
expect(handled).toBe(true);
57+
58+
const tableNode = (editor.getJSON().content || []).find((node) => node.type === 'table');
59+
expect(tableNode).toBeTruthy();
60+
expect(tableNode?.attrs?.tableProperties?.tableWidth).toEqual({
61+
value: 5000,
62+
type: 'pct',
63+
});
64+
});
65+
66+
it('merges fragmented pasted HTML tables into a single editable table', () => {
67+
({ editor } = initTestEditor({
68+
content: docData.docx,
69+
media: docData.media,
70+
mediaFiles: docData.mediaFiles,
71+
fonts: docData.fonts,
72+
mode: 'docx',
73+
}));
74+
75+
const fragmentedHtml = `
76+
<table><tbody><tr><th>Name</th><th>Role</th><th>Department</th><th>Start Date</th></tr></tbody></table>
77+
<table><tbody><tr><td>Alice Kim</td><td>Manager</td><td>Operations</td><td>2022-03-14</td></tr></tbody></table>
78+
<table><tbody><tr><td>Brian Lee</td><td>Developer</td><td>Engineering</td><td>2023-01-09</td></tr></tbody></table>
79+
<table><tbody><tr><td>Carla Gomez</td><td>Designer</td><td>Product</td><td>2021-11-22</td></tr></tbody></table>
80+
<table><tbody><tr><td>David Chen</td><td>Analyst</td><td>Finance</td><td>2024-06-03</td></tr></tbody></table>
81+
`;
82+
83+
const handled = handleHtmlPaste(fragmentedHtml, editor);
84+
expect(handled).toBe(true);
85+
86+
const tables = (editor.getJSON().content || []).filter((node) => node.type === 'table');
87+
expect(tables).toHaveLength(1);
88+
expect(tables[0]?.content).toHaveLength(5);
89+
});
90+
});

packages/super-editor/src/core/commands/insertContent.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,19 @@ describe('insertContent (integration) list export', () => {
295295
});
296296
});
297297

298+
it('defaults imported markdown tables to 100% width', async () => {
299+
const editor = await setupEditor();
300+
editor.commands.insertContent('| Query | Assessment |\n| --- | --- |\n| A | B |', { contentType: 'markdown' });
301+
await Promise.resolve();
302+
303+
const tableNode = (editor.getJSON().content || []).find((node) => node.type === 'table');
304+
expect(tableNode).toBeTruthy();
305+
expect(tableNode.attrs?.tableProperties?.tableWidth).toEqual({
306+
value: 5000,
307+
type: 'pct',
308+
});
309+
});
310+
298311
it('normalizes imported HTML table header borders for render and export parity', async () => {
299312
const editor = await setupEditor();
300313
editor.commands.insertContent(

packages/super-editor/src/core/helpers/importMarkdown.integration.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,19 @@ More text here.
126126
const numberedParagraphs = paragraphs.filter(hasNumbering);
127127
expect(numberedParagraphs).toHaveLength(2);
128128
});
129+
130+
it('defaults markdown tables to 100% width', () => {
131+
const markdown = `| Query | Assessment |
132+
| --- | --- |
133+
| A | B |`;
134+
135+
const doc = createDocFromMarkdown(markdown, editor);
136+
const firstTable = doc.content.content.find((node) => node.type.name === 'table');
137+
138+
expect(firstTable).toBeTruthy();
139+
expect(firstTable?.attrs?.tableProperties?.tableWidth).toEqual({
140+
value: 5000,
141+
type: 'pct',
142+
});
143+
});
129144
});

packages/super-editor/src/core/helpers/markdown/mdastToProseMirror.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ interface JsonMark {
6464
attrs?: Record<string, unknown>;
6565
}
6666

67+
// OOXML stores percentages in fiftieths of a percent.
68+
// 5000 = 100% table width.
69+
const FULL_WIDTH_TABLE_PCT = 5000;
70+
6771
// ---------------------------------------------------------------------------
6872
// Block-level converters
6973
// ---------------------------------------------------------------------------
@@ -299,6 +303,14 @@ function convertTable(node: MdastTable, ctx: MdastConversionContext): JsonNode {
299303

300304
return {
301305
type: 'table',
306+
attrs: {
307+
tableProperties: {
308+
tableWidth: {
309+
value: FULL_WIDTH_TABLE_PCT,
310+
type: 'pct',
311+
},
312+
},
313+
},
302314
content: rows,
303315
};
304316
}

packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const handleGoogleDocsHtml = (html, editor, view) => {
2323

2424
const htmlWithMergedLists = mergeSeparateLists(tempDiv);
2525
const flattenHtml = flattenListsInHtml(htmlWithMergedLists, editor);
26+
flattenHtml.dataset.superdocImport = 'true';
2627

2728
let doc = DOMParser.fromSchema(editor.schema).parse(flattenHtml);
2829
doc = wrapTextsInRuns(doc);

packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ describe('handleGoogleDocsHtml', () => {
8282
expect(generateNewListDefinitionMock).toHaveBeenCalledTimes(2);
8383

8484
const parsedNode = parseSpy.mock.calls[0][0];
85+
expect(parsedNode.dataset.superdocImport).toBe('true');
8586
const paragraphs = Array.from(parsedNode.querySelectorAll('p[data-num-id]'));
8687
expect(paragraphs).toHaveLength(2);
8788
expect(paragraphs[0].getAttribute('data-num-id')).toBe('410');

0 commit comments

Comments
 (0)