Skip to content

Commit f9558f9

Browse files
committed
fix(tables): column dragging on pasted tables
1 parent cceb490 commit f9558f9

3 files changed

Lines changed: 169 additions & 2 deletions

File tree

packages/super-editor/src/components/TableResizeOverlay.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,34 @@ describe('TableResizeOverlay', () => {
409409

410410
wrapper.unmount();
411411
});
412+
413+
it('should normalize clamped min width while keeping min below width', async () => {
414+
const metadata = {
415+
columns: [
416+
{ i: 0, x: 0, w: 10, min: 10, r: 1 },
417+
{ i: 1, x: 10, w: 2, min: 2, r: 1 },
418+
],
419+
};
420+
421+
const tableElement = createMockTableElement(metadata);
422+
const wrapper = mount(TableResizeOverlay, {
423+
props: {
424+
editor: createMockEditor(),
425+
visible: true,
426+
tableElement,
427+
},
428+
});
429+
430+
await nextTick();
431+
432+
const [firstCol, secondCol] = wrapper.vm.tableMetadata.columns;
433+
expect(firstCol.min).toBeLessThan(firstCol.w);
434+
expect(secondCol.min).toBeLessThan(secondCol.w);
435+
expect(firstCol.min).toBe(9);
436+
expect(secondCol.min).toBe(1);
437+
438+
wrapper.unmount();
439+
});
412440
});
413441

414442
// ==========================================================================

packages/super-editor/src/components/TableResizeOverlay.vue

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ const overlayRect = ref(null);
8484
*/
8585
const tableMetadata = ref(null);
8686
87+
/**
88+
* Normalize metadata-provided minimum width so resize remains possible.
89+
* Some imported tables report min == current width (e.g. 100/100), which
90+
* clamps drag delta to zero and makes columns feel "stuck".
91+
*
92+
* @param {number} width - Current column width in layout pixels
93+
* @param {number} rawMin - Raw minimum width from metadata
94+
* @returns {number}
95+
*/
96+
function normalizeColumnMinWidth(width, rawMin) {
97+
const safeWidth = Math.max(1, Number(width) || 1);
98+
const safeMin = Math.max(1, Number(rawMin) || 1);
99+
if (safeMin < safeWidth) return safeMin;
100+
101+
// Keep at least a practical shrink budget while guaranteeing min < width.
102+
if (safeWidth <= 2) return 1;
103+
104+
const candidate = Math.max(1, Math.max(25, Math.floor(safeWidth * 0.5)));
105+
return Math.min(safeWidth - 1, candidate);
106+
}
107+
87108
/**
88109
* Get the editor's zoom level for coordinate transformations.
89110
*
@@ -648,10 +669,10 @@ function parseTableMetadata() {
648669
);
649670
})
650671
.map((col) => ({
672+
w: Math.max(1, col.w),
673+
min: normalizeColumnMinWidth(col.w, col.min),
651674
i: col.i,
652675
x: Math.max(0, col.x),
653-
w: Math.max(1, col.w),
654-
min: Math.max(1, col.min),
655676
r: col.r,
656677
}));
657678
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { test, expect } from '../../fixtures/superdoc.js';
2+
import type { Locator, Page } from '@playwright/test';
3+
4+
test.use({ config: { toolbar: 'full', showSelection: true } });
5+
6+
const SINGLE_HTML_TABLE = `
7+
<table>
8+
<tbody>
9+
<tr><th>Name</th><th>Role</th><th>Department</th><th>Start Date</th></tr>
10+
<tr><td>Alice Kim</td><td>Manager</td><td>Operations</td><td>2022-03-14</td></tr>
11+
<tr><td>Brian Lee</td><td>Developer</td><td>Engineering</td><td>2023-01-09</td></tr>
12+
<tr><td>Carla Gomez</td><td>Designer</td><td>Product</td><td>2021-11-22</td></tr>
13+
<tr><td>David Chen</td><td>Analyst</td><td>Finance</td><td>2024-06-03</td></tr>
14+
</tbody>
15+
</table>
16+
`;
17+
18+
async function hoverColumnBoundary(page: Page, target: number | 'right-edge') {
19+
const pos = await page.evaluate((t) => {
20+
const frag = document.querySelector('.superdoc-table-fragment[data-table-boundaries]');
21+
if (!frag) throw new Error('No table fragment with boundaries found');
22+
const { columns } = JSON.parse(frag.getAttribute('data-table-boundaries')!);
23+
const col = t === 'right-edge' ? columns[columns.length - 1] : columns[t];
24+
if (!col) throw new Error(`Column ${t} not found`);
25+
const rect = frag.getBoundingClientRect();
26+
const offset = t === 'right-edge' ? -2 : 0;
27+
return { x: rect.left + col.x + col.w + offset, y: rect.top + rect.height / 2 };
28+
}, target);
29+
30+
await page.mouse.move(pos.x, pos.y);
31+
}
32+
33+
async function dragHandle(page: Page, handle: Locator, deltaX: number) {
34+
const box = await handle.boundingBox();
35+
if (!box) throw new Error('Resize handle not visible');
36+
const x = box.x + box.width / 2;
37+
const y = box.y + box.height / 2;
38+
39+
await page.mouse.move(x, y);
40+
await page.mouse.down();
41+
for (let i = 1; i <= 10; i++) {
42+
await page.mouse.move(x + (deltaX * i) / 10, y);
43+
await page.waitForTimeout(20);
44+
}
45+
await page.mouse.up();
46+
}
47+
48+
async function getTableGrid(page: Page) {
49+
return page.evaluate(() => {
50+
const doc = (window as any).editor.state.doc;
51+
let grid: any = null;
52+
doc.descendants((node: any) => {
53+
if (grid === null && node.type.name === 'table') {
54+
grid = node.attrs.grid;
55+
}
56+
});
57+
return grid;
58+
});
59+
}
60+
61+
test('pasted HTML table can be column-resized', async ({ superdoc }) => {
62+
await superdoc.page.evaluate((html) => {
63+
const editor = (window as any).editor;
64+
const dataTransfer = new DataTransfer();
65+
dataTransfer.setData('text/html', html);
66+
dataTransfer.setData('text/plain', '');
67+
const pasteEvent = new ClipboardEvent('paste', {
68+
bubbles: true,
69+
cancelable: true,
70+
clipboardData: dataTransfer,
71+
});
72+
editor.view.dom.dispatchEvent(pasteEvent);
73+
}, SINGLE_HTML_TABLE);
74+
await superdoc.waitForStable();
75+
76+
const initialState = await superdoc.page.evaluate(() => {
77+
const tableFragment = document.querySelector('.superdoc-table-fragment');
78+
const hasPmStartMarker = Boolean(tableFragment?.querySelector('[data-pm-start]'));
79+
const boundariesAttr = tableFragment?.getAttribute('data-table-boundaries') ?? null;
80+
const boundaries = boundariesAttr ? JSON.parse(boundariesAttr) : null;
81+
82+
const doc = (window as any).editor.state.doc;
83+
let tableCount = 0;
84+
let grid = null as unknown;
85+
doc.descendants((node: any) => {
86+
if (node.type.name === 'table') {
87+
tableCount += 1;
88+
if (grid === null) grid = node.attrs.grid;
89+
}
90+
});
91+
92+
return { hasPmStartMarker, tableCount, grid, boundaries };
93+
});
94+
95+
expect(initialState.tableCount).toBe(1);
96+
expect(initialState.hasPmStartMarker).toBe(true);
97+
expect(initialState.boundaries?.columns?.length).toBe(4);
98+
99+
// Retry once to reduce flake from hover/drag timing in headless browsers.
100+
for (let attempt = 0; attempt < 2; attempt += 1) {
101+
await hoverColumnBoundary(superdoc.page, 0);
102+
await superdoc.waitForStable();
103+
104+
const handle = superdoc.page.locator('.resize-handle[data-boundary-type="inner"]').first();
105+
await expect(handle).toBeAttached({ timeout: 5000 });
106+
107+
await dragHandle(superdoc.page, handle, 120);
108+
await superdoc.waitForStable();
109+
110+
const grid = await getTableGrid(superdoc.page);
111+
if (Array.isArray(grid) && grid.length === 4) {
112+
return;
113+
}
114+
}
115+
116+
const grid = await getTableGrid(superdoc.page);
117+
expect(grid).toHaveLength(4);
118+
});

0 commit comments

Comments
 (0)