Skip to content

Commit 026cbfb

Browse files
authored
fix: auto scroll to top in code block (#304)
* fix: auto scroll to top in code block * fix: Pasting code does not put it inside the bloc #261 * chore: refresh when open page
1 parent 15a08d7 commit 026cbfb

7 files changed

Lines changed: 410 additions & 14 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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+
* Code Block Paste Tests
9+
* Regression test for: https://github.com/AppFlowy-IO/AppFlowy-Web/issues/261
10+
*
11+
* Pasting content into a code block should insert the text inside the block,
12+
* not below it. The slash menu should also not open when typing "/" inside
13+
* a code block.
14+
*/
15+
test.describe('Code Block Paste', () => {
16+
const testEmail = generateRandomEmail();
17+
const isMac = process.platform === 'darwin';
18+
const cmdKey = isMac ? 'Meta' : 'Control';
19+
20+
test.beforeEach(async ({ page }) => {
21+
setupPageErrorHandling(page);
22+
await page.setViewportSize({ width: 1280, height: 720 });
23+
});
24+
25+
async function setupEditor(
26+
page: Page,
27+
request: import('@playwright/test').APIRequestContext
28+
) {
29+
await signInAndWaitForApp(page, request, testEmail);
30+
await expect(page).toHaveURL(/\/app/, { timeout: 30000 });
31+
await page.waitForTimeout(1000);
32+
33+
await createDocumentPageAndNavigate(page);
34+
await EditorSelectors.firstEditor(page).click({ force: true });
35+
await page.waitForTimeout(500);
36+
}
37+
38+
/**
39+
* Helper: insert a code block via the slash menu and wait for it to appear.
40+
*/
41+
async function insertCodeBlock(page: Page) {
42+
await page.keyboard.type('/', { delay: 50 });
43+
await page.waitForTimeout(1000);
44+
45+
const slashPanel = page.getByTestId('slash-panel');
46+
await expect(slashPanel).toBeVisible({ timeout: 10000 });
47+
48+
await page.keyboard.type('code', { delay: 50 });
49+
await page.waitForTimeout(500);
50+
51+
await page.getByTestId('slash-menu-code').click({ force: true });
52+
await page.waitForTimeout(1000);
53+
54+
await expect(page.locator('[data-block-type="code"]')).toBeVisible({ timeout: 5000 });
55+
}
56+
57+
test('pasting plain text into a code block should insert inside, not below', async ({
58+
page,
59+
request,
60+
}) => {
61+
await setupEditor(page, request);
62+
await insertCodeBlock(page);
63+
64+
// Focus the code block
65+
const codeBlock = page.locator('[data-block-type="code"]');
66+
await codeBlock.click({ force: true });
67+
await page.waitForTimeout(300);
68+
69+
// Copy multi-line text to clipboard and paste it
70+
const pasteText = 'const a = 1;\nconst b = 2;\nconst c = a + b;';
71+
72+
await page.evaluate(async (text) => {
73+
await navigator.clipboard.writeText(text);
74+
}, pasteText);
75+
76+
await page.keyboard.press(`${cmdKey}+v`);
77+
await page.waitForTimeout(500);
78+
79+
// The text should appear INSIDE the code block, not below it
80+
const codeBlockText = await codeBlock.innerText();
81+
expect(codeBlockText).toContain('const a = 1;');
82+
expect(codeBlockText).toContain('const b = 2;');
83+
expect(codeBlockText).toContain('const c = a + b;');
84+
});
85+
86+
test('pasting HTML-formatted code into a code block should insert as plain text inside', async ({
87+
page,
88+
request,
89+
}) => {
90+
await setupEditor(page, request);
91+
await insertCodeBlock(page);
92+
93+
const codeBlock = page.locator('[data-block-type="code"]');
94+
await codeBlock.click({ force: true });
95+
await page.waitForTimeout(300);
96+
97+
// Simulate pasting HTML (e.g. from ChatGPT) that contains code
98+
// The code block should receive the plain text, not create new blocks
99+
const htmlContent = '<pre><code>function hello() {\n return "world";\n}</code></pre>';
100+
const plainContent = 'function hello() {\n return "world";\n}';
101+
102+
await page.evaluate(
103+
async ({ html, plain }) => {
104+
const clipboardItem = new ClipboardItem({
105+
'text/html': new Blob([html], { type: 'text/html' }),
106+
'text/plain': new Blob([plain], { type: 'text/plain' }),
107+
});
108+
109+
await navigator.clipboard.write([clipboardItem]);
110+
},
111+
{ html: htmlContent, plain: plainContent }
112+
);
113+
114+
await page.keyboard.press(`${cmdKey}+v`);
115+
await page.waitForTimeout(500);
116+
117+
// All pasted text should be inside the code block
118+
const codeBlockText = await codeBlock.innerText();
119+
expect(codeBlockText).toContain('function hello()');
120+
expect(codeBlockText).toContain('return "world"');
121+
122+
// No new blocks should have been created below the code block
123+
const allBlocks = page.locator('[data-block-type]');
124+
const blockTypes = await allBlocks.evaluateAll((els) =>
125+
els.map((el) => el.getAttribute('data-block-type'))
126+
);
127+
const codeBlockCount = blockTypes.filter((t) => t === 'code').length;
128+
expect(codeBlockCount).toBe(1);
129+
});
130+
131+
test('typing "/" inside a code block should not open the slash menu', async ({
132+
page,
133+
request,
134+
}) => {
135+
await setupEditor(page, request);
136+
await insertCodeBlock(page);
137+
138+
const codeBlock = page.locator('[data-block-type="code"]');
139+
await codeBlock.click({ force: true });
140+
await page.waitForTimeout(300);
141+
142+
// Type a "/" inside the code block
143+
await page.keyboard.type('/', { delay: 50 });
144+
await page.waitForTimeout(1000);
145+
146+
// The slash panel should NOT appear
147+
const slashPanel = page.getByTestId('slash-panel');
148+
await expect(slashPanel).not.toBeVisible();
149+
150+
// The "/" should be typed into the code block
151+
const codeBlockText = await codeBlock.innerText();
152+
expect(codeBlockText).toContain('/');
153+
});
154+
});
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
* Code Block Scroll Stability Tests
9+
* Regression test for: https://github.com/AppFlowy-IO/AppFlowy-Web/issues/300
10+
*
11+
* When editing a code block further down on a page, pressing Enter should NOT
12+
* cause the page to scroll back to the top.
13+
*/
14+
test.describe('Code Block Scroll Stability', () => {
15+
const testEmail = generateRandomEmail();
16+
17+
test.beforeEach(async ({ page }) => {
18+
setupPageErrorHandling(page);
19+
await page.setViewportSize({ width: 1280, height: 720 });
20+
});
21+
22+
/**
23+
* Helper: sign in and create a blank document page with the editor focused.
24+
*/
25+
async function setupEditor(
26+
page: Page,
27+
request: import('@playwright/test').APIRequestContext
28+
) {
29+
await signInAndWaitForApp(page, request, testEmail);
30+
await expect(page).toHaveURL(/\/app/, { timeout: 30000 });
31+
await page.waitForTimeout(1000);
32+
33+
await createDocumentPageAndNavigate(page);
34+
await EditorSelectors.firstEditor(page).click({ force: true });
35+
await page.waitForTimeout(500);
36+
}
37+
38+
/**
39+
* Helper: fill the editor with enough paragraph blocks to force vertical
40+
* scrolling, then append a code block at the bottom.
41+
*/
42+
async function fillPageAndInsertCodeBlock(page: Page) {
43+
// Type many lines of filler text to push content below the fold
44+
for (let i = 0; i < 25; i++) {
45+
await page.keyboard.type(`Filler paragraph line ${i + 1}`, { delay: 10 });
46+
await page.keyboard.press('Enter');
47+
}
48+
49+
await page.waitForTimeout(300);
50+
51+
// Insert a code block via the slash menu
52+
await page.keyboard.type('/', { delay: 50 });
53+
await page.waitForTimeout(1000);
54+
55+
const slashPanel = page.getByTestId('slash-panel');
56+
await expect(slashPanel).toBeVisible({ timeout: 10000 });
57+
58+
// Search for "code" and click the Code option
59+
await page.keyboard.type('code', { delay: 50 });
60+
await page.waitForTimeout(500);
61+
62+
await page.getByTestId('slash-menu-code').click({ force: true });
63+
await page.waitForTimeout(1000);
64+
65+
// Verify the code block was created
66+
await expect(page.locator('[data-block-type="code"]')).toBeVisible({ timeout: 5000 });
67+
}
68+
69+
/**
70+
* Helper: returns the current vertical scroll position of the main scroll container.
71+
*/
72+
async function getScrollTop(page: Page): Promise<number> {
73+
return page.evaluate(() => {
74+
const el = document.querySelector('.appflowy-scroll-container');
75+
76+
return el ? el.scrollTop : 0;
77+
});
78+
}
79+
80+
test('pressing Enter in a code block below the fold should not scroll to top', async ({
81+
page,
82+
request,
83+
}) => {
84+
await setupEditor(page, request);
85+
await fillPageAndInsertCodeBlock(page);
86+
87+
// Scroll to the bottom so the code block is visible
88+
await page.evaluate(() => {
89+
const el = document.querySelector('.appflowy-scroll-container');
90+
91+
if (el) el.scrollTop = el.scrollHeight;
92+
});
93+
await page.waitForTimeout(500);
94+
95+
// Click inside the code block to focus it
96+
const codeBlock = page.locator('[data-block-type="code"]');
97+
await codeBlock.click({ force: true });
98+
await page.waitForTimeout(300);
99+
100+
// Type some initial text so we have content to press Enter in
101+
await page.keyboard.type('function hello() {', { delay: 20 });
102+
await page.waitForTimeout(200);
103+
104+
// Record scroll position before pressing Enter
105+
const scrollBefore = await getScrollTop(page);
106+
107+
// The scroll should be > 0 since the code block is below the fold
108+
expect(scrollBefore).toBeGreaterThan(50);
109+
110+
// Press Enter multiple times inside the code block
111+
for (let i = 0; i < 5; i++) {
112+
await page.keyboard.press('Enter');
113+
await page.waitForTimeout(200);
114+
}
115+
116+
// Allow any async scroll effects to settle
117+
await page.waitForTimeout(500);
118+
119+
// Verify: scroll should NOT have jumped to the top
120+
const scrollAfter = await getScrollTop(page);
121+
122+
// Allow a small tolerance for natural scroll adjustments (e.g. code block
123+
// growing taller may shift the viewport slightly), but the scroll position
124+
// must not have dropped to near zero.
125+
expect(scrollAfter).toBeGreaterThan(scrollBefore * 0.5);
126+
});
127+
128+
test('typing in a code block below the fold should maintain scroll position', async ({
129+
page,
130+
request,
131+
}) => {
132+
await setupEditor(page, request);
133+
await fillPageAndInsertCodeBlock(page);
134+
135+
// Scroll to bottom
136+
await page.evaluate(() => {
137+
const el = document.querySelector('.appflowy-scroll-container');
138+
139+
if (el) el.scrollTop = el.scrollHeight;
140+
});
141+
await page.waitForTimeout(500);
142+
143+
// Focus the code block
144+
const codeBlock = page.locator('[data-block-type="code"]');
145+
await codeBlock.click({ force: true });
146+
await page.waitForTimeout(300);
147+
148+
const scrollBefore = await getScrollTop(page);
149+
expect(scrollBefore).toBeGreaterThan(50);
150+
151+
// Type several lines of code with Enter presses
152+
await page.keyboard.type('const x = 1;', { delay: 20 });
153+
await page.keyboard.press('Enter');
154+
await page.keyboard.type('const y = 2;', { delay: 20 });
155+
await page.keyboard.press('Enter');
156+
await page.keyboard.type('const z = x + y;', { delay: 20 });
157+
await page.keyboard.press('Enter');
158+
await page.keyboard.type('console.log(z);', { delay: 20 });
159+
await page.waitForTimeout(500);
160+
161+
const scrollAfter = await getScrollTop(page);
162+
163+
// Scroll must not have jumped to the top
164+
expect(scrollAfter).toBeGreaterThan(scrollBefore * 0.5);
165+
});
166+
});

src/components/app/layers/AppBusinessLayer.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ export const AppBusinessLayer: FC<AppBusinessLayerProps> = ({ children }) => {
151151

152152
const [routeViewExists, setRouteViewExists] = useState<boolean | null>(null);
153153

154+
// Reset when viewId changes so stale false from a previous page
155+
// doesn't flash "Page not found" for the new page.
156+
const prevViewIdRef = useRef(viewId);
157+
158+
if (viewId !== prevViewIdRef.current) {
159+
prevViewIdRef.current = viewId;
160+
161+
if (routeViewExists === false) {
162+
setRouteViewExists(null);
163+
}
164+
}
165+
154166
const setRouteViewExistsCache = useCallback((key: string, exists: boolean) => {
155167
const cache = routeViewExistsCacheRef.current;
156168

@@ -198,17 +210,11 @@ export const AppBusinessLayer: FC<AppBusinessLayerProps> = ({ children }) => {
198210
? Date.now() - cached.checkedAt >= ROUTE_VIEW_EXISTS_REVALIDATE_MS
199211
: true;
200212

201-
// Cache policy:
202-
// - false can be trusted (confirmed not-found)
203-
// - true is reused, but periodically revalidated while route view is
204-
// outside the currently loaded shallow tree to avoid stale positives.
205-
if (cached?.exists === false) {
206-
setRouteViewExists(false);
207-
return;
208-
}
209-
210-
if (cached?.exists === true && !cacheExpired) {
211-
setRouteViewExists(true);
213+
// Cache policy: both true and false are periodically revalidated.
214+
// A false entry may be stale if the view was created or synced after
215+
// the original check (e.g. sync lag, eventual consistency).
216+
if (cached && !cacheExpired) {
217+
setRouteViewExists(cached.exists);
212218
return;
213219
}
214220

0 commit comments

Comments
 (0)