Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 129 additions & 40 deletions src/static/js/pad_editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,49 +210,138 @@ const padeditor = (() => {

exports.padeditor = padeditor;

exports.focusOnLine = (ace) => {
// If a number is in the URI IE #L124 go to that line number
const getHashedLineNumber = () => {
const lineNumber = window.location.hash.substr(1);
if (lineNumber) {
if (lineNumber[0] === 'L') {
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
const lineNumberInt = parseInt(lineNumber.substr(1));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must say this code is quite a mess. I like the new style with better function naming 😊.

if (lineNumberInt) {
const $inner = $('iframe[name="ace_outer"]').contents().find('iframe')
.contents().find('#innerdocbody');
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
if (line.length !== 0) {
let offsetTop = line.offset().top;
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
const hasMobileLayout = $('body').hasClass('mobile-layout');
if (!hasMobileLayout) {
offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
}
const $outerdocHTML = $('iframe[name="ace_outer"]').contents()
.find('#outerdocbody').parent();
$outerdoc.css({top: `${offsetTop}px`}); // Chrome
$outerdocHTML.animate({scrollTop: offsetTop}); // needed for FF
const node = line[0];
ace.callWithAce((ace) => {
const selection = {
startPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
endPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
};
ace.ace_setSelection(selection);
});
}
if (!lineNumber || lineNumber[0] !== 'L') return null;
const lineNumberInt = parseInt(lineNumber.substr(1));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just fyi. Substr should not be used as it is deprecated. You can use substring which should work in your case identically. You only need to pay attention because the second argument does not anymore mean length but end position. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substr

return Number.isInteger(lineNumberInt) && lineNumberInt > 0 ? lineNumberInt : null;
};

const focusOnHashedLine = (ace, lineNumberInt) => {
const $aceOuter = $('iframe[name="ace_outer"]');
const $outerdoc = $aceOuter.contents().find('#outerdocbody');
const $inner = $aceOuter.contents().find('iframe').contents().find('#innerdocbody');
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
if (line.length === 0) return false;
Comment on lines +223 to +225
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Wrong line dom selector 🐞 Bug ≡ Correctness

focusOnHashedLine() and the settle loop use $inner.find('div:nth-child(N)'), which can match
nested <div> elements inside earlier lines and scroll/select the wrong element. This breaks #L…
navigation for pads/plugins that introduce nested divs in line content.
Agent Prompt
### Issue description
The anchor scroll logic locates the target line via `$inner.find(`div:nth-child(${lineNumberInt})`)`, which searches all descendant divs and can select nested divs inside earlier lines. This can cause scrolling and `ace_setSelection()` to target the wrong node.

### Issue Context
In the inner iframe, `#innerdocbody` is cleared and then populated with the editor line nodes as direct children. Plugins can mutate each line node’s HTML (`acePostWriteDomLineHTML`), including inserting nested `<div>` elements.

### Fix Focus Areas
- src/static/js/pad_editor.ts[220-257]
- src/static/js/pad_editor.ts[259-268]

### Implementation notes
- Replace the descendant selector with a direct-child selection, for example:
  - `const $line = $inner.children('div').eq(lineNumberInt - 1);`
  - or `const $line = $inner.find(`> div:nth-child(${lineNumberInt})`);`
- Apply the same change in both `focusOnHashedLine()` and `getCurrentTargetOffset()` so the settle loop and the actual focus use the same element.
- Keep the existing `length === 0` guard and continue using the resolved node for `ace_setSelection()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


let offsetTop = line.offset().top;
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
const hasMobileLayout = $('body').hasClass('mobile-layout');
if (!hasMobileLayout) offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
const $outerdocHTML = $aceOuter.contents().find('#outerdocbody').parent();
$outerdoc.css({top: `${offsetTop}px`}); // Chrome
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this only for chrome? Is it still relevant?

// Direct scrollTop() (was previously $.animate({scrollTop}) for Firefox). The animation
// workaround is no longer needed because focusOnLine() reapplies the scroll on a settle
// interval until layout stabilises, which covers Firefox's late-layout behaviour without
// an animated scroll fighting concurrent layout shifts.
$outerdocHTML.scrollTop(offsetTop);
const node = line[0];
ace.callWithAce((ace) => {
const selection = {
startPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
endPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
};
ace.ace_setSelection(selection);
});
return true;
};

exports.focusOnLine = (ace) => {
const lineNumberInt = getHashedLineNumber();
if (lineNumberInt == null) return;
const $aceOuter = $('iframe[name="ace_outer"]');
const getCurrentTargetOffset = () => {
const $inner = $aceOuter.contents().find('iframe').contents().find('#innerdocbody');
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
if (line.length === 0) return null;
return line.offset().top;
};

// Settle window: keep correcting the scroll position while late content (images,
// plugin-rendered blocks) shifts the target line's offsetTop. The interval ends when
// any of the following becomes true:
// (a) maxSettleDuration (10s) has elapsed — hard ceiling
// (b) the user interacts (see stop() below) — never fight the user
// (c) at least minSettleDuration (2s) has elapsed AND the target offset has not
// moved by more than offsetEpsilon for stableTicksRequired consecutive ticks
// (image loads / plugin renders past the 2s window are still corrected; brief
// early stability does not exit the loop prematurely)
// (d) the target line is missing from the DOM for missingTicksRequired consecutive
// ticks (the anchor doesn't exist — bail rather than spin to maxSettleDuration)
// Sub-pixel tolerance avoids strict-equality flapping on fractional offsets.
const maxSettleDuration = 10000;
const minSettleDuration = 2000;
const settleInterval = 250;
const stableTicksRequired = 4;
const offsetEpsilon = 1;
const missingTicksRequired = 8; // 2s of consecutive misses → assume invalid anchor
const startTime = Date.now();
let intervalId: number | null = null;
let lastOffset: number | null = null;
let stableTicks = 0;
let missingTicks = 0;

const userEventNames = ['wheel', 'touchmove', 'keydown', 'mousedown'];
const docs: Document[] = [];
const stop = () => {
if (intervalId != null) {
window.clearInterval(intervalId);
intervalId = null;
}
for (const doc of docs) {
for (const name of userEventNames) doc.removeEventListener(name, stop, true);
}
docs.length = 0;
};

const focusUntilStable = () => {
if (Date.now() - startTime >= maxSettleDuration) {
stop();
return;
}
const currentOffsetTop = getCurrentTargetOffset();
if (currentOffsetTop == null) {
missingTicks += 1;
if (missingTicks >= missingTicksRequired) stop();
return;
}
missingTicks = 0;
focusOnHashedLine(ace, lineNumberInt);
if (lastOffset != null && Math.abs(currentOffsetTop - lastOffset) < offsetEpsilon) {
stableTicks += 1;
if (stableTicks >= stableTicksRequired
&& Date.now() - startTime >= minSettleDuration) {
stop();
}
} else {
stableTicks = 0;
}
lastOffset = currentOffsetTop;
};

focusUntilStable();
intervalId = window.setInterval(focusUntilStable, settleInterval);
Comment on lines +307 to +333
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Immediate focusuntilstable() scroll 📎 Requirement gap ≡ Correctness

The new #L... behavior scrolls immediately on load via focusUntilStable() instead of deferring
until the pad layout has fully settled. This can still cause an initial incorrect viewport position
before late-loading content (for example images) finishes affecting layout.
Agent Prompt
## Issue description
The implementation scrolls immediately and then re-applies scrolling for a fixed settle window, but it does not actually *defer* scrolling until the layout has stabilized.

## Issue Context
Compliance requires that `#L...` navigation scroll occurs only after async content (e.g., images/plugins) has finished loading and the resulting layout is stable, to prevent landing on the wrong line/region.

## Fix Focus Areas
- src/static/js/pad_editor.ts[271-283]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
// Stop fighting the user: any deliberate scroll, tap, click, or keystroke cancels the
// reapply loop so late layout corrections do not steal focus once the user takes over.
// Listen on both the ace_outer and ace_inner documents in capture phase so we see the
// user's intent even if inner handlers stopPropagation().
const outerDoc = ($aceOuter.contents()[0] as any) as Document | undefined;
const innerIframe = $aceOuter.contents().find('iframe')[0] as HTMLIFrameElement | undefined;
const innerDoc = innerIframe?.contentDocument;
for (const doc of [outerDoc, innerDoc]) {
if (!doc) continue;
docs.push(doc);
for (const name of userEventNames) doc.addEventListener(name, stop, true);
}
// End of setSelection / set Y position of editor
};
113 changes: 113 additions & 0 deletions src/tests/frontend-new/specs/anchor_scroll.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {expect, test} from "@playwright/test";
import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper";

test.describe('anchor scrolling', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});

test('reapplies #L scroll after earlier content changes height', async ({page}) => {
await goToNewPad(page);
const padUrl = page.url();
await clearPadContent(page);
await writeToPad(page, Array.from({length: 30}, (_v, i) => `Line ${i + 1}`).join('\n'));
await page.waitForTimeout(1000);

await page.goto('about:blank');
await page.goto(`${padUrl}#L20`);
await page.waitForSelector('iframe[name="ace_outer"]');
await page.waitForSelector('#editorcontainer.initialized');
await page.waitForTimeout(2000);

const outerDoc = page.frameLocator('iframe[name="ace_outer"]').locator('#outerdocbody');
const firstLine = page.frameLocator('iframe[name="ace_outer"]')
.frameLocator('iframe')
.locator('#innerdocbody > div')
.first();
const targetLine = page.frameLocator('iframe[name="ace_outer"]')
.frameLocator('iframe')
.locator('#innerdocbody > div')
.nth(19);

const getScrollTop = async () => await outerDoc.evaluate(
(el) => el.parentElement?.scrollTop || 0);
const getTargetViewportTop = async () => await targetLine.evaluate((el) => el.getBoundingClientRect().top);

await expect.poll(getScrollTop).toBeGreaterThan(10);
const initialViewportTop = await getTargetViewportTop();

await firstLine.evaluate((el) => {
const filler = document.createElement('div');
filler.style.height = '400px';
el.appendChild(filler);
});

await expect.poll(async () => {
const currentViewportTop = await getTargetViewportTop();
return Math.abs(currentViewportTop - initialViewportTop);
}).toBeLessThanOrEqual(80);
});

test('reapply loop exits early once the target offset is stable', async ({page}) => {
await goToNewPad(page);
const padUrl = page.url();
await clearPadContent(page);
await writeToPad(page, Array.from({length: 30}, (_v, i) => `Line ${i + 1}`).join('\n'));
await page.waitForTimeout(1000);

await page.goto('about:blank');
await page.goto(`${padUrl}#L20`);
await page.waitForSelector('iframe[name="ace_outer"]');
await page.waitForSelector('#editorcontainer.initialized');

const outerDoc = page.frameLocator('iframe[name="ace_outer"]').locator('#outerdocbody');
const getScrollTop = async () => await outerDoc.evaluate(
(el) => el.parentElement?.scrollTop || 0);

await expect.poll(getScrollTop).toBeGreaterThan(10);
// Wait past minSettleDuration (2s) plus stableTicksRequired (4 * 250ms = 1s) plus
// slack, well under the 10s hard timeout. After early-exit, scrolling away from the
// anchor must not be reverted by another reapply tick.
await page.waitForTimeout(3500);

await outerDoc.evaluate((el) => {
if (el.parentElement) el.parentElement.scrollTop = 0;
});
await page.waitForTimeout(1500);
expect(await getScrollTop()).toBeLessThanOrEqual(20);
});

test('user scroll cancels the reapply loop so navigation is not locked', async ({page}) => {
await goToNewPad(page);
const padUrl = page.url();
await clearPadContent(page);
await writeToPad(page, Array.from({length: 30}, (_v, i) => `Line ${i + 1}`).join('\n'));
await page.waitForTimeout(1000);

await page.goto('about:blank');
await page.goto(`${padUrl}#L20`);
await page.waitForSelector('iframe[name="ace_outer"]');
await page.waitForSelector('#editorcontainer.initialized');

const outerDoc = page.frameLocator('iframe[name="ace_outer"]').locator('#outerdocbody');
const getScrollTop = async () => await outerDoc.evaluate(
(el) => el.parentElement?.scrollTop || 0);

await expect.poll(getScrollTop).toBeGreaterThan(10);

// User interacts with the pad. The anchor-scroll handler listens for
// wheel/mousedown/keydown/touchmove on the outer iframe document and must cancel
// its reapply loop. We dispatch a mousedown on the outer document, then reset
// scrollTop to 0 and verify it stays there.
await outerDoc.evaluate((el) => {
const doc = el.ownerDocument;
doc.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}));
if (el.parentElement) el.parentElement.scrollTop = 0;
});

// Give the reapply loop several ticks to attempt a re-scroll. If cancellation worked,
// scrollTop stays near 0 instead of snapping back to the anchor.
await page.waitForTimeout(1500);
expect(await getScrollTop()).toBeLessThanOrEqual(20);
});
});
14 changes: 14 additions & 0 deletions src/tests/frontend/specs/scrollTo.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ describe('scrollTo.js', function () {
return (topOffset >= 100);
});
});

it('reapplies the scroll when earlier content changes height after load', async function () {
const chrome$ = helper.padChrome$;
const inner$ = helper.padInner$;
const getTopOffset = () => parseInt(chrome$('iframe').first('iframe')
.contents().find('#outerdocbody').css('top')) || 0;

await helper.waitForPromise(() => getTopOffset() >= 100);
const initialTopOffset = getTopOffset();

inner$('#innerdocbody > div').first().css('height', '400px');

await helper.waitForPromise(() => getTopOffset() > initialTopOffset + 200);
});
});

describe('doesnt break on weird hash input', function () {
Expand Down
Loading