Skip to content

Commit 65076b8

Browse files
JohnMcLearCopilot
andcommitted
fix: delay anchor line scrolling until layout settles
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d62ab65 commit 65076b8

File tree

3 files changed

+133
-42
lines changed

3 files changed

+133
-42
lines changed

src/static/js/pad_editor.ts

Lines changed: 69 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -210,49 +210,76 @@ const padeditor = (() => {
210210

211211
exports.padeditor = padeditor;
212212

213-
exports.focusOnLine = (ace) => {
214-
// If a number is in the URI IE #L124 go to that line number
213+
const getHashedLineNumber = () => {
215214
const lineNumber = window.location.hash.substr(1);
216-
if (lineNumber) {
217-
if (lineNumber[0] === 'L') {
218-
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
219-
const lineNumberInt = parseInt(lineNumber.substr(1));
220-
if (lineNumberInt) {
221-
const $inner = $('iframe[name="ace_outer"]').contents().find('iframe')
222-
.contents().find('#innerdocbody');
223-
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
224-
if (line.length !== 0) {
225-
let offsetTop = line.offset().top;
226-
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
227-
const hasMobileLayout = $('body').hasClass('mobile-layout');
228-
if (!hasMobileLayout) {
229-
offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
230-
}
231-
const $outerdocHTML = $('iframe[name="ace_outer"]').contents()
232-
.find('#outerdocbody').parent();
233-
$outerdoc.css({top: `${offsetTop}px`}); // Chrome
234-
$outerdocHTML.animate({scrollTop: offsetTop}); // needed for FF
235-
const node = line[0];
236-
ace.callWithAce((ace) => {
237-
const selection = {
238-
startPoint: {
239-
index: 0,
240-
focusAtStart: true,
241-
maxIndex: 1,
242-
node,
243-
},
244-
endPoint: {
245-
index: 0,
246-
focusAtStart: true,
247-
maxIndex: 1,
248-
node,
249-
},
250-
};
251-
ace.ace_setSelection(selection);
252-
});
253-
}
254-
}
215+
if (!lineNumber || lineNumber[0] !== 'L') return null;
216+
const lineNumberInt = parseInt(lineNumber.substr(1));
217+
return Number.isInteger(lineNumberInt) && lineNumberInt > 0 ? lineNumberInt : null;
218+
};
219+
220+
const focusOnHashedLine = (ace, lineNumberInt) => {
221+
const $aceOuter = $('iframe[name="ace_outer"]');
222+
const $outerdoc = $aceOuter.contents().find('#outerdocbody');
223+
const $inner = $aceOuter.contents().find('iframe').contents().find('#innerdocbody');
224+
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
225+
if (line.length === 0) return false;
226+
227+
let offsetTop = line.offset().top;
228+
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
229+
const hasMobileLayout = $('body').hasClass('mobile-layout');
230+
if (!hasMobileLayout) offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
231+
const $outerdocHTML = $aceOuter.contents().find('#outerdocbody').parent();
232+
$outerdoc.css({top: `${offsetTop}px`}); // Chrome
233+
$outerdocHTML.scrollTop(offsetTop);
234+
const node = line[0];
235+
ace.callWithAce((ace) => {
236+
const selection = {
237+
startPoint: {
238+
index: 0,
239+
focusAtStart: true,
240+
maxIndex: 1,
241+
node,
242+
},
243+
endPoint: {
244+
index: 0,
245+
focusAtStart: true,
246+
maxIndex: 1,
247+
node,
248+
},
249+
};
250+
ace.ace_setSelection(selection);
251+
});
252+
return true;
253+
};
254+
255+
exports.focusOnLine = (ace) => {
256+
const lineNumberInt = getHashedLineNumber();
257+
if (lineNumberInt == null) return;
258+
const getCurrentTargetOffset = () => {
259+
const $aceOuter = $('iframe[name="ace_outer"]');
260+
const $inner = $aceOuter.contents().find('iframe').contents().find('#innerdocbody');
261+
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
262+
if (line.length === 0) return null;
263+
return line.offset().top;
264+
};
265+
266+
const maxSettleDuration = 10000;
267+
const settleInterval = 250;
268+
const startTime = Date.now();
269+
let intervalId = null;
270+
271+
const focusUntilStable = () => {
272+
if (Date.now() - startTime >= maxSettleDuration) {
273+
window.clearInterval(intervalId);
274+
return;
255275
}
256-
}
276+
const currentOffsetTop = getCurrentTargetOffset();
277+
if (currentOffsetTop == null) return;
278+
279+
focusOnHashedLine(ace, lineNumberInt);
280+
};
281+
282+
focusUntilStable();
283+
intervalId = window.setInterval(focusUntilStable, settleInterval);
257284
// End of setSelection / set Y position of editor
258285
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {expect, test} from "@playwright/test";
2+
import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper";
3+
4+
test.describe('anchor scrolling', () => {
5+
test.beforeEach(async ({context}) => {
6+
await context.clearCookies();
7+
});
8+
9+
test('reapplies #L scroll after earlier content changes height', async ({page}) => {
10+
await goToNewPad(page);
11+
const padUrl = page.url();
12+
await clearPadContent(page);
13+
await writeToPad(page, Array.from({length: 30}, (_v, i) => `Line ${i + 1}`).join('\n'));
14+
await page.waitForTimeout(1000);
15+
16+
await page.goto('about:blank');
17+
await page.goto(`${padUrl}#L20`);
18+
await page.waitForSelector('iframe[name="ace_outer"]');
19+
await page.waitForSelector('#editorcontainer.initialized');
20+
await page.waitForTimeout(2000);
21+
22+
const outerDoc = page.frameLocator('iframe[name="ace_outer"]').locator('#outerdocbody');
23+
const firstLine = page.frameLocator('iframe[name="ace_outer"]')
24+
.frameLocator('iframe')
25+
.locator('#innerdocbody > div')
26+
.first();
27+
const targetLine = page.frameLocator('iframe[name="ace_outer"]')
28+
.frameLocator('iframe')
29+
.locator('#innerdocbody > div')
30+
.nth(19);
31+
32+
const getScrollTop = async () => await outerDoc.evaluate(
33+
(el) => el.parentElement?.scrollTop || 0);
34+
const getTargetViewportTop = async () => await targetLine.evaluate((el) => el.getBoundingClientRect().top);
35+
36+
await expect.poll(getScrollTop).toBeGreaterThan(10);
37+
const initialViewportTop = await getTargetViewportTop();
38+
39+
await firstLine.evaluate((el) => {
40+
const filler = document.createElement('div');
41+
filler.style.height = '400px';
42+
el.appendChild(filler);
43+
});
44+
45+
await expect.poll(async () => {
46+
const currentViewportTop = await getTargetViewportTop();
47+
return Math.abs(currentViewportTop - initialViewportTop);
48+
}).toBeLessThanOrEqual(80);
49+
});
50+
});

src/tests/frontend/specs/scrollTo.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ describe('scrollTo.js', function () {
1515
return (topOffset >= 100);
1616
});
1717
});
18+
19+
it('reapplies the scroll when earlier content changes height after load', async function () {
20+
const chrome$ = helper.padChrome$;
21+
const inner$ = helper.padInner$;
22+
const getTopOffset = () => parseInt(chrome$('iframe').first('iframe')
23+
.contents().find('#outerdocbody').css('top')) || 0;
24+
25+
await helper.waitForPromise(() => getTopOffset() >= 100);
26+
const initialTopOffset = getTopOffset();
27+
28+
inner$('#innerdocbody > div').first().css('height', '400px');
29+
30+
await helper.waitForPromise(() => getTopOffset() > initialTopOffset + 200);
31+
});
1832
});
1933

2034
describe('doesnt break on weird hash input', function () {

0 commit comments

Comments
 (0)