Commit 04e8025f authored by Robert Knight's avatar Robert Knight

Ignore fixed-positioned content which choosing a scroll anchor

When choosing a scroll anchor to preserve the visible content after toggling
side-by-side mode, ignore content in elements with `position: fixed` or
`position: sticky` styles, since this content won't significantly shift its
position as a result of the document content being resized.
parent 73acd7d2
...@@ -97,14 +97,27 @@ function textRect(text, start = 0, end = text.data.length) { ...@@ -97,14 +97,27 @@ function textRect(text, start = 0, end = text.data.length) {
return textRectRange.getBoundingClientRect(); return textRectRange.getBoundingClientRect();
} }
/** @param {Element} element */
function hasFixedPosition(element) {
switch (getComputedStyle(element).position) {
case 'fixed':
case 'sticky':
return true;
default:
return false;
}
}
/** /**
* Yield all the text node descendants of `root` that intersect `rect`. * Yield all the text node descendants of `root` that intersect `rect`.
* *
* @param {Element} root * @param {Element} root
* @param {DOMRect} rect * @param {DOMRect} rect
* @param {(el: Element) => boolean} shouldVisit - Optional filter that determines
* whether to visit a subtree
* @return {Generator<Text>} * @return {Generator<Text>}
*/ */
function* textNodesInRect(root, rect) { function* textNodesInRect(root, rect, shouldVisit = () => true) {
/** @type {Node|null} */ /** @type {Node|null} */
let node = root.firstChild; let node = root.firstChild;
while (node) { while (node) {
...@@ -113,8 +126,8 @@ function* textNodesInRect(root, rect) { ...@@ -113,8 +126,8 @@ function* textNodesInRect(root, rect) {
const elementRect = element.getBoundingClientRect(); const elementRect = element.getBoundingClientRect();
// Only examine subtrees which are visible and intersect the viewport. // Only examine subtrees which are visible and intersect the viewport.
if (rectIntersects(elementRect, rect)) { if (shouldVisit(element) && rectIntersects(elementRect, rect)) {
yield* textNodesInRect(element, rect); yield* textNodesInRect(element, rect, shouldVisit);
} }
} else if (node.nodeType === Node.TEXT_NODE) { } else if (node.nodeType === Node.TEXT_NODE) {
const text = /** @type {Text} */ (node); const text = /** @type {Text} */ (node);
...@@ -129,7 +142,8 @@ function* textNodesInRect(root, rect) { ...@@ -129,7 +142,8 @@ function* textNodesInRect(root, rect) {
} }
/** /**
* Find content within an element to use as an anchor when scrolling. * Find content within an element to use as an anchor when applying a layout
* change to the document.
* *
* @param {Element} scrollRoot * @param {Element} scrollRoot
* @return {Range|null} - Range to use as an anchor or `null` if a suitable * @return {Range|null} - Range to use as an anchor or `null` if a suitable
...@@ -151,7 +165,17 @@ function getScrollAnchor(scrollRoot) { ...@@ -151,7 +165,17 @@ function getScrollAnchor(scrollRoot) {
// Find the first word (non-whitespace substring of a text node) that is fully // Find the first word (non-whitespace substring of a text node) that is fully
// visible in the viewport. // visible in the viewport.
textNodeLoop: for (let textNode of textNodesInRect(scrollRoot, viewport)) {
// Text inside fixed-position elements is ignored because its position won't
// be affected by a layout change and so it makes a poor scroll anchor.
/** @param {Element} el */
const shouldVisit = el => !hasFixedPosition(el);
textNodeLoop: for (let textNode of textNodesInRect(
scrollRoot,
viewport,
shouldVisit
)) {
let textLen = 0; let textLen = 0;
// Visit all the non-whitespace substrings of the text node. // Visit all the non-whitespace substrings of the text node.
......
...@@ -163,6 +163,40 @@ the fighting was.`; ...@@ -163,6 +163,40 @@ the fighting was.`;
); );
}); });
it('ignores fixed-position content when choosing a scroll anchor', () => {
// Set up a DOM structure that emulates a page with a sticky heading:
//
// <div> // Scroll root
// <div> // Inner container
// <nav> // Fixed-position navbar
// <p>..</p> // Content
// </div>
// </div>
//
// Here the `<nav>` contains the top-left most text node in the viewport,
// but we should it because of its fixed position.
//
// The inner container is used to check that the element filtering is
// applied as the DOM tree is recursively traversed.
const nav = document.createElement('nav');
nav.style.position = 'fixed';
nav.style.left = '0px';
nav.style.top = '0px';
nav.textContent = 'Some heading';
const inner = document.createElement('div');
inner.append(nav, content);
scrollRoot.append(inner);
scrollRoot.scrollTop = 200;
const delta = preserveScrollPosition(() => {
scrollRoot.style.width = '150px';
}, scrollRoot);
// The scroll position should be adjusted. This would be zero if the
// text in the <nav> element was used as a scroll anchor.
assert.notEqual(delta, 0);
});
it('does not restore the scroll position if no anchor content could be found', () => { it('does not restore the scroll position if no anchor content could be found', () => {
// Fill content with empty text, which cannot be used as a scroll anchor. // Fill content with empty text, which cannot be used as a scroll anchor.
content.textContent = ' '.repeat(documentText.length); content.textContent = ' '.repeat(documentText.length);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment