Skip to content

Commit 4cde60d

Browse files
appflowyclaude
andcommitted
fix: prevent paste from indenting blocks under the current block
`convertSlateFragmentTo` treated Slate's inner text-wrapper (type:'text') as a regular block, wrapping every pasted block in an extra Paragraph and causing each paste to be indented one level. Slate's `insertFragment` then nested those blocks under the cursor's parent, which also broke Backspace after deleting the parent. Fix paste in three places: - `convertSlateFragmentTo`: preserve text-wrapper nodes as-is, only treat real BlockType elements as blocks. - `withInsertData`: recover the Slate fragment from the `data-slate-fragment` HTML attribute when the `application/x-slate-fragment` clipboard MIME entry is missing (matches slate-dom's regex). Wrap decode in try/catch so malformed clipboard data falls through to the text/HTML handlers. - New `insertFragmentAsSiblings` writes pasted blocks directly to the YJS doc as siblings of the current block, mirroring `Transforms.insertFragment` semantics: deletes expanded selection first, replaces the current block if empty, and places the cursor at the end of the last inserted block. Adds a Playwright spec covering the indent regression, the backspace-after-delete follow-up, and paste-over-selection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1fe826f commit 4cde60d

3 files changed

Lines changed: 449 additions & 40 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
import { EditorSelectors } from '../../../support/selectors';
3+
import { generateRandomEmail, setupPageErrorHandling } from '../../../support/test-config';
4+
import { signInAndWaitForApp } from '../../../support/auth-flow-helpers';
5+
import { createDocumentPageAndNavigate } from '../../../support/page-utils';
6+
7+
/**
8+
* Repro: type "1","2","3","4" on separate lines, select all, copy,
9+
* then paste on a new line. The pasted blocks should NOT be indented
10+
* (i.e. should appear as siblings of the original blocks at the same
11+
* indent level), but a bug causes them to be nested under the last block.
12+
*/
13+
test.describe('Editor - Paste Indentation', () => {
14+
const testEmail = generateRandomEmail();
15+
const isMac = process.platform === 'darwin';
16+
const cmdKey = isMac ? 'Meta' : 'Control';
17+
18+
test.beforeEach(async ({ page }) => {
19+
setupPageErrorHandling(page);
20+
await page.setViewportSize({ width: 1280, height: 720 });
21+
});
22+
23+
async function setupEditor(
24+
page: Page,
25+
request: import('@playwright/test').APIRequestContext
26+
) {
27+
await signInAndWaitForApp(page, request, testEmail);
28+
await expect(page).toHaveURL(/\/app/, { timeout: 30000 });
29+
await page.waitForTimeout(1000);
30+
31+
await createDocumentPageAndNavigate(page);
32+
await EditorSelectors.firstEditor(page).click({ force: true });
33+
await page.waitForTimeout(500);
34+
}
35+
36+
test('pasting copied lines on a new line should not indent the pasted blocks', async ({
37+
page,
38+
request,
39+
}) => {
40+
await setupEditor(page, request);
41+
42+
// Type 1\n2\n3\n4
43+
await page.keyboard.type('1');
44+
await page.keyboard.press('Enter');
45+
await page.keyboard.type('2');
46+
await page.keyboard.press('Enter');
47+
await page.keyboard.type('3');
48+
await page.keyboard.press('Enter');
49+
await page.keyboard.type('4');
50+
await page.waitForTimeout(300);
51+
52+
// Select all and copy
53+
await page.keyboard.press(`${cmdKey}+a`);
54+
await page.waitForTimeout(200);
55+
await page.keyboard.press(`${cmdKey}+a`);
56+
await page.waitForTimeout(200);
57+
await page.keyboard.press(`${cmdKey}+c`);
58+
await page.waitForTimeout(300);
59+
60+
// Move to end and create a new empty line, then paste
61+
await page.keyboard.press(`${cmdKey}+End`);
62+
await page.waitForTimeout(100);
63+
await page.keyboard.press('End');
64+
await page.keyboard.press('Enter');
65+
await page.waitForTimeout(200);
66+
67+
await page.keyboard.press(`${cmdKey}+v`);
68+
await page.waitForTimeout(800);
69+
70+
// Collect text blocks with their indent depth (number of parent
71+
// [data-block-type] ancestors inside the editor).
72+
const editor = EditorSelectors.slateEditor(page);
73+
const blocks = await editor.locator('[data-block-type]').evaluateAll((els) =>
74+
els.map((el) => {
75+
let depth = 0;
76+
let parent: HTMLElement | null = el.parentElement;
77+
while (parent) {
78+
if (parent.hasAttribute('data-block-type')) depth += 1;
79+
parent = parent.parentElement;
80+
}
81+
return {
82+
text: (el as HTMLElement).innerText.trim(),
83+
depth,
84+
};
85+
})
86+
);
87+
88+
// Keep only the leaf text blocks for the values we typed/pasted
89+
const numeric = blocks.filter((b) => /^[1-4]$/.test(b.text));
90+
91+
// Expect 8 entries: original 1,2,3,4 and pasted 1,2,3,4
92+
expect(numeric.length).toBe(8);
93+
94+
// All numeric blocks should be at the same depth (no indentation
95+
// introduced by paste). If the bug is present, the pasted blocks
96+
// will have greater depth than the originals.
97+
const depths = numeric.map((b) => b.depth);
98+
const minDepth = Math.min(...depths);
99+
const maxDepth = Math.max(...depths);
100+
expect(maxDepth).toBe(minDepth);
101+
102+
// Strict assertion on paste position. The empty placeholder block created
103+
// by Enter must be REPLACED by the pasted content, so the top-level
104+
// sequence (in document order) must be exactly:
105+
// "1","2","3","4","1","2","3","4"
106+
// optionally followed by a single trailing empty block that the editor
107+
// maintains for the cursor — but NO empty block between the original "4"
108+
// and the first pasted "1".
109+
const topLevel = blocks
110+
.filter((b) => b.depth === minDepth)
111+
.map((b) => b.text);
112+
113+
// Drop only a final trailing empty block (cursor-tail), not interior ones.
114+
const trimmed = topLevel.length > 0 && topLevel[topLevel.length - 1] === ''
115+
? topLevel.slice(0, -1)
116+
: topLevel;
117+
118+
expect(trimmed).toEqual(['1', '2', '3', '4', '1', '2', '3', '4']);
119+
120+
// No empty placeholder should remain anywhere between non-empty entries.
121+
expect(trimmed.every((t) => t !== '')).toBe(true);
122+
});
123+
124+
test('after deleting the last source line, Backspace should still remove blocks', async ({
125+
page,
126+
request,
127+
}) => {
128+
await setupEditor(page, request);
129+
130+
await page.keyboard.type('1');
131+
await page.keyboard.press('Enter');
132+
await page.keyboard.type('2');
133+
await page.keyboard.press('Enter');
134+
await page.keyboard.type('3');
135+
await page.keyboard.press('Enter');
136+
await page.keyboard.type('4');
137+
await page.waitForTimeout(300);
138+
139+
await page.keyboard.press(`${cmdKey}+a`);
140+
await page.waitForTimeout(150);
141+
await page.keyboard.press(`${cmdKey}+a`);
142+
await page.waitForTimeout(150);
143+
await page.keyboard.press(`${cmdKey}+c`);
144+
await page.waitForTimeout(300);
145+
146+
await page.keyboard.press(`${cmdKey}+End`);
147+
await page.keyboard.press('End');
148+
await page.keyboard.press('Enter');
149+
await page.waitForTimeout(150);
150+
await page.keyboard.press(`${cmdKey}+v`);
151+
await page.waitForTimeout(800);
152+
153+
const editor = EditorSelectors.slateEditor(page);
154+
155+
// Delete the "4" on the source line: navigate to it and remove the char.
156+
// After paste cursor is at end of pasted content; move back to original "4".
157+
// Easiest: just press Backspace until "4" is gone, then one more Backspace
158+
// and assert something actually changed in the document.
159+
const before = await editor.innerText();
160+
161+
// Click on the original "4" block at depth 0
162+
const originalFour = editor
163+
.locator('[data-block-type]')
164+
.filter({ hasText: /^4$/ })
165+
.first();
166+
167+
await originalFour.click();
168+
await page.keyboard.press('End');
169+
await page.keyboard.press('Backspace'); // remove '4'
170+
await page.waitForTimeout(150);
171+
172+
// Now the original block is empty. Backspace again must delete/merge —
173+
// the document text must change again.
174+
const afterDelete4 = await editor.innerText();
175+
176+
await page.keyboard.press('Backspace');
177+
await page.waitForTimeout(200);
178+
179+
const afterSecondBackspace = await editor.innerText();
180+
181+
expect(afterSecondBackspace).not.toBe(afterDelete4);
182+
expect(before).not.toBe(afterSecondBackspace);
183+
});
184+
185+
test('pasting over an expanded selection replaces the selected range', async ({
186+
page,
187+
request,
188+
}) => {
189+
await setupEditor(page, request);
190+
191+
// Source: 1,2,3,4
192+
await page.keyboard.type('1');
193+
await page.keyboard.press('Enter');
194+
await page.keyboard.type('2');
195+
await page.keyboard.press('Enter');
196+
await page.keyboard.type('3');
197+
await page.keyboard.press('Enter');
198+
await page.keyboard.type('4');
199+
await page.waitForTimeout(300);
200+
201+
// Select all → copy
202+
await page.keyboard.press(`${cmdKey}+a`);
203+
await page.waitForTimeout(150);
204+
await page.keyboard.press(`${cmdKey}+a`);
205+
await page.waitForTimeout(150);
206+
await page.keyboard.press(`${cmdKey}+c`);
207+
await page.waitForTimeout(300);
208+
209+
// Now paste back OVER the same selection (still selected after copy).
210+
// Mirrors Slate's `Transforms.insertFragment` semantics: the expanded
211+
// range must be deleted first, then the pasted blocks inserted in its
212+
// place — so the document should contain exactly one copy of 1..4.
213+
await page.keyboard.press(`${cmdKey}+v`);
214+
await page.waitForTimeout(800);
215+
216+
const editor = EditorSelectors.slateEditor(page);
217+
const topLevelBlocks = await editor.locator('[data-block-type]').evaluateAll((els) =>
218+
els.map((el) => {
219+
let depth = 0;
220+
let parent: HTMLElement | null = el.parentElement;
221+
while (parent) {
222+
if (parent.hasAttribute('data-block-type')) depth += 1;
223+
parent = parent.parentElement;
224+
}
225+
return { text: (el as HTMLElement).innerText.trim(), depth };
226+
})
227+
);
228+
229+
const minDepth = Math.min(...topLevelBlocks.map((b) => b.depth));
230+
const top = topLevelBlocks.filter((b) => b.depth === minDepth).map((b) => b.text);
231+
const trimmed = top.length > 0 && top[top.length - 1] === '' ? top.slice(0, -1) : top;
232+
233+
// Exactly one copy of the source — not duplicated.
234+
expect(trimmed).toEqual(['1', '2', '3', '4']);
235+
});
236+
});

0 commit comments

Comments
 (0)