diff --git a/src/static/js/caretPosition.ts b/src/static/js/caretPosition.ts
index 5134a0ed072..86f23b7ac7c 100644
--- a/src/static/js/caretPosition.ts
+++ b/src/static/js/caretPosition.ts
@@ -3,24 +3,36 @@
// One rep.line(div) can be broken in more than one line in the browser.
// This function is useful to get the caret position of the line as
// is represented by the browser
+//
+// NOTE: every DOM lookup here has to go through the editor document that is
+// passed in, *not* through the ambient `window` / `document`. This module is
+// bundled into the pad's top-level window, while the caret lives in the
+// ace_inner iframe nested inside ace_outer. Reading the top window's selection
+// always yields an empty selection, which used to make getPosition() return
+// null and crash the callers below (#8038).
import {Position, RepModel, RepNode} from "./types/RepModel";
-export const getPosition = () => {
- const range = getSelectionRange();
- // @ts-ignore
- if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody') return null;
+export const getPosition = (doc: Document): Position | null => {
+ const range = getSelectionRange(doc);
+ if (!range || getBodyOf(range.endContainer)?.id !== 'innerdocbody') return null;
// When there's a
or any element that has no height, we can't get the dimension of the
// element where the caret is. As we can't get the element height, we create a text node to get
// the dimensions on the position.
const clonedRange = createSelectionRange(range);
- const shadowCaret = $(document.createTextNode('|'));
- clonedRange.insertNode(shadowCaret[0]);
- clonedRange.selectNode(shadowCaret[0]);
+ const shadowCaret = doc.createTextNode('|');
+ clonedRange.insertNode(shadowCaret);
+ clonedRange.selectNode(shadowCaret);
const line = getPositionOfElementOrSelection(clonedRange);
shadowCaret.remove();
return line;
};
+// The caret node is usually a text node, so climb to an element first.
+const getBodyOf = (node: Node) => {
+ const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
+ return element ? element.closest('body') : null;
+};
+
const createSelectionRange = (range: Range) => {
const clonedRange = range.cloneRange();
@@ -33,7 +45,7 @@ const createSelectionRange = (range: Range) => {
return clonedRange;
};
-const getPositionOfRepLineAtOffset = (node: any, offset: number) => {
+const getPositionOfRepLineAtOffset = (node: any, offset: number, doc: Document) => {
// it is not a text node, so we cannot make a selection
if (node.tagName === 'BR' || node.tagName === 'EMPTY') {
return getPositionOfElementOrSelection(node);
@@ -43,7 +55,7 @@ const getPositionOfRepLineAtOffset = (node: any, offset: number) => {
node = node.nextSibling as any;
}
- const newRange = new Range();
+ const newRange = doc.createRange();
newRange.setStart(node, offset);
newRange.setEnd(node, offset);
const linePosition = getPositionOfElementOrSelection(newRange);
@@ -66,29 +78,31 @@ const getPositionOfElementOrSelection = (element: Range):Position => {
// where is the top of the previous line
// [2] the line before is part of another rep line. It's possible this line has different margins
// height. So we have to get the exactly position of the line
-export const getPositionTopOfPreviousBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
- let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
- const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);
-
- // the caret is in the beginning of a rep line, so the previous browser line
- // is the last line browser line of the a rep line
- if (isCaretLineFirstBrowserLine) { // [2]
- const lineBeforeCaretLine = rep.selStart[0] - 1;
- const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
- const linePosition =
- getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);
- previousLineTop = linePosition.top;
- }
- return previousLineTop;
-};
+export const getPositionTopOfPreviousBrowserLine =
+ (caretLinePosition: Position, rep: RepModel, doc: Document) => {
+ let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
+ const isCaretLineFirstBrowserLine =
+ caretLineIsFirstBrowserLine(caretLinePosition.top, rep, doc);
+
+ // the caret is in the beginning of a rep line, so the previous browser line
+ // is the last line browser line of the a rep line
+ if (isCaretLineFirstBrowserLine) { // [2]
+ const lineBeforeCaretLine = rep.selStart[0] - 1;
+ const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
+ const linePosition =
+ getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep, doc);
+ previousLineTop = linePosition.top;
+ }
+ return previousLineTop;
+ };
-const caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel) => {
+const caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel, doc: Document) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const firstRootNode = getFirstRootChildNode(lineNode);
// to get the position of the node we get the position of the first char
- const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1);
+ const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1, doc);
return positionOfFirstRootNode.top === caretLineTop;
};
@@ -101,13 +115,13 @@ const getFirstRootChildNode = (node: RepNode) => {
}
};
-const getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel) => {
+const getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel, doc: Document) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
// we get the position of the line in the last char of it
const lastRootChildNodePosition =
- getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
+ getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length, doc);
return lastRootChildNodePosition;
};
@@ -127,31 +141,32 @@ const getLastRootChildNode = (node: RepNode) => {
// So, we can use the caret line to calculate the bottom of the line.
// [2] the next line is part of another rep line.
// It's possible this line has different dimensions, so we have to get the exactly dimension of it
-export const getBottomOfNextBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
- let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
- const isCaretLineLastBrowserLine =
- caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);
-
- // the caret is at the end of a rep line, so we can get the next browser line dimension
- // using the position of the first char of the next rep line
- if (isCaretLineLastBrowserLine) { // [2]
- const nextLineAfterCaretLine = rep.selStart[0] + 1;
- const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
- const linePosition =
- getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);
- nextLineBottom = linePosition.bottom;
- }
- return nextLineBottom;
-};
+export const getBottomOfNextBrowserLine =
+ (caretLinePosition: Position, rep: RepModel, doc: Document) => {
+ let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
+ const isCaretLineLastBrowserLine =
+ caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep, doc);
+
+ // the caret is at the end of a rep line, so we can get the next browser line dimension
+ // using the position of the first char of the next rep line
+ if (isCaretLineLastBrowserLine) { // [2]
+ const nextLineAfterCaretLine = rep.selStart[0] + 1;
+ const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
+ const linePosition =
+ getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep, doc);
+ nextLineBottom = linePosition.bottom;
+ }
+ return nextLineBottom;
+ };
-const caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel) => {
+const caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel, doc: Document) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
// we take a rep line and get the position of the last char of it
const lastRootChildNodePosition =
- getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
+ getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length, doc);
return lastRootChildNodePosition.top === caretLineTop;
};
@@ -181,20 +196,21 @@ export const getNextVisibleLine = (line: number, rep: RepModel): number => {
const isLineVisible = (line: number, rep: RepModel) => rep.lines.atIndex(line).lineNode.offsetHeight > 0;
-const getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel) => {
+const getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel, doc: Document) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const firstRootChildNode = getFirstRootChildNode(lineNode);
// we can get the position of the line, getting the position of the first char of the rep line
- const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1);
+ const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1, doc);
return firstRootChildNodePosition;
};
-const getSelectionRange = () => {
- if (!window.getSelection) {
- return;
+const getSelectionRange = (doc: Document) => {
+ const win = doc.defaultView;
+ if (!win || !win.getSelection) {
+ return null;
}
- const selection = window.getSelection();
+ const selection = win.getSelection();
if (selection && selection.type !== 'None' && selection.rangeCount > 0) {
return selection.getRangeAt(0);
} else {
diff --git a/src/static/js/scroll.ts b/src/static/js/scroll.ts
index d4fe5a5d3b4..9a56d755c12 100644
--- a/src/static/js/scroll.ts
+++ b/src/static/js/scroll.ts
@@ -1,5 +1,5 @@
import {getBottomOfNextBrowserLine, getNextVisibleLine, getPosition, getPositionTopOfPreviousBrowserLine, getPreviousVisibleLine} from './caretPosition';
-import {Position, RepModel, RepNode, WindowElementWithScrolling} from "./types/RepModel";
+import {Position, RepModel, RepNode} from "./types/RepModel";
class Scroll {
@@ -18,6 +18,15 @@ class Scroll {
this.rootDocument = document;
}
+ // `outerWin` is the ace_outer *iframe element*, not a window — this module is
+ // bundled into the pad's top-level window and ace2_inner.ts hands us the
+ // element. Scrolling therefore has to go through its contentWindow: calling
+ // scrollTo()/scrollBy() on the element itself silently does nothing, because
+ // an iframe element has no scrollable box of its own.
+ _getOuterWin(): Window | null {
+ return this.outerWin.contentWindow;
+ };
+
scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep: RepModel, isScrollableEvent: boolean, innerHeight: number) {
// are we placing the caret on the line at the bottom of viewport?
// And if so, do we need to scroll the editor, as defined on the settings.json?
@@ -52,7 +61,19 @@ class Scroll {
}
}
+ // The editor document, i.e. the document of the ace_inner iframe nested
+ // inside ace_outer. This module runs in the pad's top-level window, so every
+ // caret measurement has to be made against this document rather than the
+ // ambient one. Resolved on demand because browsers (Firefox in particular)
+ // may replace an iframe's document after load.
+ _getInnerDoc(): Document | null {
+ const innerFrame = this.doc.getElementsByName('ace_inner')[0] as HTMLIFrameElement | undefined;
+ return innerFrame ? innerFrame.contentDocument : null;
+ };
+
_isCaretAtTheBottomOfViewport(rep: RepModel) {
+ const innerDoc = this._getInnerDoc();
+ if (!innerDoc) return false;
// computing a line position using getBoundingClientRect() is expensive.
// (obs: getBoundingClientRect() is called on caretPosition.getPosition())
// To avoid that, we only call this function when it is possible that the
@@ -66,9 +87,11 @@ class Scroll {
this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
// check if the caret is in the bottom of the viewport
- const caretLinePosition = getPosition()!;
+ const caretLinePosition = getPosition(innerDoc);
+ // no caret in the pad (e.g. focus is outside the editor), so nothing to scroll to
+ if (!caretLinePosition) return false;
const viewportBottom = this._getViewPortTopBottom().bottom;
- const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep);
+ const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep, innerDoc);
return nextLineBottom > viewportBottom;
}
return false;
@@ -124,9 +147,9 @@ class Scroll {
};
_getScrollXY() {
- const win = this.outerWin as WindowElementWithScrolling;
+ const win = this._getOuterWin();
const odoc = this.doc;
- if (typeof (win.pageYOffset) === 'number') {
+ if (win && typeof (win.pageYOffset) === 'number') {
return {
x: win.pageXOffset,
y: win.pageYOffset,
@@ -139,29 +162,33 @@ class Scroll {
y: docel.scrollTop,
};
}
+ return {x: 0, y: 0};
};
getScrollX() {
- return this._getScrollXY()!.x;
+ return this._getScrollXY().x;
};
getScrollY () {
- return this._getScrollXY()!.y;
+ return this._getScrollXY().y;
};
setScrollX(x: number) {
- this.outerWin.scrollTo(x, this.getScrollY());
+ this.setScrollXY(x, this.getScrollY());
};
setScrollY(y: number) {
- this.outerWin.scrollTo(this.getScrollX(), y);
+ this.setScrollXY(this.getScrollX(), y);
};
setScrollXY(x: number, y: number) {
- this.outerWin.scrollTo(x, y);
+ const win = this._getOuterWin();
+ if (win) win.scrollTo(x, y);
};
_isCaretAtTheTopOfViewport(rep: RepModel) {
+ const innerDoc = this._getInnerDoc();
+ if (!innerDoc) return false;
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
const firstLineVisibleBeforeCaretLine =
@@ -171,16 +198,18 @@ class Scroll {
const lineBeforeCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
- const caretLinePosition = getPosition(); // get the position of the browser line
+ const caretLinePosition = getPosition(innerDoc); // get the position of the browser line
+ // no caret in the pad (e.g. focus is outside the editor), so nothing to scroll to
+ if (!caretLinePosition) return false;
const viewportPosition = this._getViewPortTopBottom();
const viewportTop = viewportPosition.top;
const viewportBottom = viewportPosition.bottom;
- const caretLineIsBelowViewportTop = caretLinePosition!.bottom >= viewportTop;
- const caretLineIsAboveViewportBottom = caretLinePosition!.top < viewportBottom;
+ const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
+ const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
const caretLineIsInsideOfViewport =
caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
- const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition!, rep);
+ const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition, rep, innerDoc);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
}
@@ -229,7 +258,8 @@ class Scroll {
};
_scrollYPageWithoutAnimation(pixelsToScroll: number) {
- this.outerWin.scrollBy(0, pixelsToScroll);
+ const win = this._getOuterWin();
+ if (win) win.scrollBy(0, pixelsToScroll);
};
_scrollYPageWithAnimation(pixelsToScroll: number, durationOfAnimationToShowFocusline: number) {
@@ -260,12 +290,14 @@ class Scroll {
scrollNodeVerticallyIntoView(rep: RepModel, innerHeight: number) {
+ const innerDoc = this._getInnerDoc();
+ if (!innerDoc) return;
const viewport = this._getViewPortTopBottom();
// when the selection changes outside of the viewport the browser automatically scrolls the line
// to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now
// So, when the line scrolled gets outside of the viewport we let the browser handle it.
- const linePosition = getPosition();
+ const linePosition = getPosition(innerDoc);
if (linePosition) {
const distanceOfTopOfViewport = linePosition.top - viewport.top;
const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;
@@ -278,9 +310,11 @@ class Scroll {
} else if (caretIsBelowOfViewport) {
// setTimeout is required here as line might not be fully rendered onto the pad
setTimeout(() => {
- const outer = window.parent;
- // scroll to the very end of the pad outer
- outer.scrollTo(0, outer[0].innerHeight);
+ const outer = this._getOuterWin();
+ // scroll to the very end of the pad outer. The inner frame is as tall
+ // as the whole document, so its scrollHeight is the end of the pad;
+ // scrollTo() clamps to the outer frame's maximum scroll offset.
+ if (outer) outer.scrollTo(0, innerDoc.documentElement.scrollHeight);
}, 150);
// if the above setTimeout and functionality is removed then hitting an enter
// key while on the last line wont be an optimal user experience
diff --git a/src/tests/frontend-new/specs/scroll_caret_viewport.spec.ts b/src/tests/frontend-new/specs/scroll_caret_viewport.spec.ts
new file mode 100644
index 00000000000..b334c95d3cd
--- /dev/null
+++ b/src/tests/frontend-new/specs/scroll_caret_viewport.spec.ts
@@ -0,0 +1,213 @@
+import {expect, Page, test} from "@playwright/test";
+import {randomUUID} from "node:crypto";
+
+// Regression tests for https://github.com/ether/etherpad/issues/8038
+//
+// `scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewport`
+// is off by default, so this suite turns it on client-side by patching
+// clientVars before pad.ts reads it (Scroll caches the settings object in its
+// constructor). With the setting on, moving the caret onto the line at the
+// bottom edge of the viewport runs caretPosition.getPosition(), which used to
+// look at the *top* window's selection — the caret lives in the ace_inner
+// iframe, so that selection is always empty and getPosition() returned null,
+// crashing in getBottomOfNextBrowserLine ("can't access property bottom").
+
+const LINE_COUNT = 40;
+
+const padLines = (page: Page) => page.frameLocator('iframe[name="ace_outer"]')
+ .frameLocator('iframe[name="ace_inner"]')
+ .locator('#innerdocbody > div');
+
+const getOuterScrollY = async (page: Page) => await page.evaluate(() => {
+ const outerFrame = document.getElementsByName('ace_outer')[0] as HTMLIFrameElement;
+ return outerFrame.contentWindow!.pageYOffset;
+});
+
+// Typing and the editor's own scroll handling keep moving the viewport for a
+// while after the last keystroke; measuring before that settles makes the
+// numbers below meaningless (and is flaky under parallel workers).
+const waitForStableScroll = async (page: Page) => {
+ let previous = NaN;
+ for (let i = 0; i < 24; i++) {
+ const current = await getOuterScrollY(page);
+ if (current === previous) return current;
+ previous = current;
+ await page.waitForTimeout(250);
+ }
+ return previous;
+};
+
+const preparePad = async (page: Page, percentageBelowViewport: number) => {
+ await page.addInitScript(({pct}) => {
+ let stored: unknown;
+ Object.defineProperty(window, 'clientVars', {
+ configurable: true,
+ get() { return stored; },
+ set(v) {
+ if (v != null && typeof v === 'object') {
+ const cv = v as {
+ padDeletionToken?: string | null,
+ scrollWhenFocusLineIsOutOfViewport?: Record,
+ };
+ // The one-time deletion-token modal steals focus and eats clicks.
+ cv.padDeletionToken = null;
+ cv.scrollWhenFocusLineIsOutOfViewport = {
+ percentage: {editionAboveViewport: 0, editionBelowViewport: pct},
+ duration: 0,
+ scrollWhenCaretIsInTheLastLineOfViewport: true,
+ percentageToScrollWhenUserPressesArrowUp: 0,
+ };
+ }
+ stored = v;
+ },
+ });
+ }, {pct: percentageBelowViewport});
+
+ const errors: string[] = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+
+ await page.goto(`http://localhost:9001/p/SCROLL_CARET_${randomUUID()}`);
+ await page.waitForSelector('iframe[name="ace_outer"]');
+ await page.waitForSelector('#editorcontainer.initialized');
+ const body = page.frameLocator('iframe[name="ace_outer"]')
+ .frameLocator('iframe[name="ace_inner"]')
+ .locator('#innerdocbody[contenteditable="true"]');
+ await body.waitFor({state: 'attached'});
+ await body.click();
+
+ // Enough lines that the pad overflows the viewport several times over.
+ // insertText rather than keyboard.type: per-key events race Etherpad's
+ // input pipeline under Firefox + WITH_PLUGINS load and drop characters.
+ const lines = Array.from({length: LINE_COUNT}, (_v, i) => `line ${i}`);
+ for (let i = 0; i < lines.length; i++) {
+ await page.keyboard.insertText(lines[i]);
+ await page.keyboard.press('Enter');
+ }
+ await waitForStableScroll(page);
+
+ // Typing leaves the caret on the last line with the pad scrolled to its end.
+ // Put the caret back near the top; the editor follows the caret, so this is
+ // a viewport position that stays put.
+ await padLines(page).nth(3).click();
+ await waitForStableScroll(page);
+
+ return {errors};
+};
+
+// Puts the caret on the line the editor considers to be at the bottom of the
+// viewport — the case #8038 is about. "At the bottom" is not simply the last
+// visible line: scroll.ts fires when the *next* browser line reaches past the
+// viewport bottom, measured from text rects rather than line boxes, so this
+// mirrors that predicate rather than guessing at a line. Clicking through a
+// locator would scroll the target into view first and move the very edge we
+// are aiming at, so it clicks by coordinate.
+const clickCaretLineAtBottomOfViewport = async (page: Page) => {
+ const target = await page.evaluate(() => {
+ const outerFrame = document.getElementsByName('ace_outer')[0] as HTMLIFrameElement;
+ const outerWin = outerFrame.contentWindow!;
+ const outerDoc = outerWin.document;
+ const innerFrame = outerDoc.getElementsByName('ace_inner')[0] as HTMLIFrameElement;
+ const innerDoc = innerFrame.contentWindow!.document;
+ const frameRect = outerFrame.getBoundingClientRect();
+
+ // scroll.ts's _getViewPortTopBottom()
+ const editorTop = (document.getElementsByTagName('iframe')[0] as HTMLElement).offsetTop;
+ const padTop = parseInt(outerWin.getComputedStyle(outerFrame).paddingTop) || 0;
+ const viewportBottom = outerWin.pageYOffset +
+ outerDoc.documentElement.clientHeight - editorTop - padTop;
+
+ // caretPosition.ts measures browser lines from the text rect of a line's
+ // first character, not from the line div's box.
+ const firstCharBottom = (line: HTMLElement) => {
+ let node: Node = line;
+ while (node.firstChild) node = node.firstChild;
+ const range = innerDoc.createRange();
+ const length = node.nodeType === Node.TEXT_NODE ? (node as Text).length : 0;
+ range.setStart(node, 0);
+ range.setEnd(node, Math.min(1, length));
+ return range.getBoundingClientRect().bottom;
+ };
+
+ const lines = [...innerDoc.body.children].filter(
+ (e) => e.tagName === 'DIV') as HTMLElement[];
+ // the first line that is itself visible while the line after it reaches
+ // past the bottom edge — exactly when _isCaretAtTheBottomOfViewport() is
+ // true, so clicking here is what the feature reacts to
+ const index = lines.findIndex((line, i) => {
+ if (i + 1 >= lines.length) return false;
+ const top = line.offsetTop;
+ return top >= outerWin.pageYOffset && top < viewportBottom &&
+ firstCharBottom(lines[i + 1]) > viewportBottom;
+ });
+ const geometry = {index, viewportBottom, scrollY: outerWin.pageYOffset,
+ clientHeight: outerDoc.documentElement.clientHeight, lineCount: lines.length};
+ if (index < 0) return {geometry, x: 0, y: 0, found: false};
+
+ const line = lines[index];
+ const y = frameRect.top + (line.offsetTop - outerWin.pageYOffset) +
+ Math.min(6, line.offsetHeight / 2);
+ return {geometry, found: true,
+ x: Math.round(frameRect.left + 80), y: Math.round(y)};
+ });
+ if (target.found) await page.mouse.click(target.x, target.y);
+ return target;
+};
+
+test.describe('scroll when caret is in the last line of the viewport', () => {
+ // A small, fixed viewport keeps the numbers below predictable: 40 lines
+ // overflow it several times over, and the jump this feature adds is large
+ // compared to the line-sized steps the browser scrolls on its own.
+ test.use({viewport: {width: 900, height: 500}});
+
+ test.beforeEach(async ({context}) => {
+ await context.clearCookies();
+ });
+
+ test('moving the caret to the bottom of the viewport does not throw',
+ async ({page}) => {
+ const {errors} = await preparePad(page, 0);
+ await clickCaretLineAtBottomOfViewport(page);
+ await page.waitForTimeout(500);
+ // arrow keys walk the caret across the edge as well
+ for (let i = 0; i < 4; i++) {
+ await page.keyboard.press('ArrowDown');
+ await page.waitForTimeout(200);
+ }
+ expect(errors).toEqual([]);
+ });
+
+ test('scrolls the caret line clear of the bottom edge of the viewport',
+ async ({page, browserName}) => {
+ // Firefox applies its own scroll-into-view for the clicked line before
+ // this check runs, which leaves the "is the next line past the bottom
+ // edge?" comparison decided by a pixel or two — too marginal to assert
+ // on. The behaviour is the same there; only the trigger is hard to hit
+ // reproducibly. The crash regression above still covers both browsers.
+ test.skip(browserName === 'firefox',
+ 'trigger geometry is sub-pixel marginal in Firefox');
+ const {errors} = await preparePad(page, 0.6);
+
+ // Landing the caret on the bottom-edge line can take a couple of goes:
+ // each attempt re-measures, and the editor may have scrolled in
+ // between. The jump we are looking for is the caret line being put 60%
+ // of a viewport clear of the bottom edge; the browser never scrolls
+ // that far on its own — line by line it moves a line height at a time
+ // — and it only happens when the caret geometry is read from the
+ // editor document.
+ let biggestJump = 0;
+ const attempts = [];
+ for (let attempt = 0; attempt < 4 && biggestJump <= 300; attempt++) {
+ const before = await getOuterScrollY(page);
+ const target = await clickCaretLineAtBottomOfViewport(page);
+ await page.waitForTimeout(750);
+ const jump = await getOuterScrollY(page) - before;
+ attempts.push({...target.geometry, before, jump});
+ biggestJump = Math.max(biggestJump, jump);
+ await waitForStableScroll(page);
+ }
+
+ expect(biggestJump, `attempts: ${JSON.stringify(attempts)}`)
+ .toBeGreaterThan(300);
+ expect(errors).toEqual([]);
+ });
+});