diff --git a/app/assets/javascripts/lib/utils/resize_observer.js b/app/assets/javascripts/lib/utils/resize_observer.js index ded1080ee813a5adc7e8bd0c1b4e9bfaff1346bc..995439a5fc3a485e7b3b473c74abc677c5e4e356 100644 --- a/app/assets/javascripts/lib/utils/resize_observer.js +++ b/app/assets/javascripts/lib/utils/resize_observer.js @@ -12,6 +12,7 @@ export function createResizeObserver() { /** * Watches for change in size of a container element (e.g. for lazy-loaded images) * and scrolls the target note to the top of the content area. + * Stops watching if the target element is scrolled out of viewport * * @param {Object} options * @param {string} options.targetId - id of element to scroll to @@ -38,16 +39,21 @@ export function scrollToTargetOnResize({ // can't tell difference between user and el.scrollTo, so use a flag let skipProgrammaticScrollEvent = false; + let intersectionObserver = null; + let targetElement = null; + let contentTopValue = contentTop(); + const containerEl = document.querySelector(container); const ro = createResizeObserver(); function handleScroll() { if (skipProgrammaticScrollEvent) { + contentTopValue = contentTop(); skipProgrammaticScrollEvent = false; return; } currentScrollPosition = scrollContainerIsDocument ? window.scrollY : scrollContainer.scrollTop; - userScrollOffset = currentScrollPosition - targetTop; + userScrollOffset = currentScrollPosition - targetTop - contentTopValue; } function addScrollListener() { @@ -67,16 +73,39 @@ export function scrollToTargetOnResize({ } } + function setupIntersectionObserver() { + intersectionObserver = new IntersectionObserver( + (entries) => { + const [entry] = entries; + + // if element gets scrolled off screen then remove listeners + if (!entry.isIntersecting) { + // eslint-disable-next-line no-use-before-define + cleanup(); + } + }, + { + root: scrollContainerIsDocument ? null : scrollContainer, + }, + ); + + intersectionObserver.observe(targetElement); + } + function keepTargetAtTop() { if (document.activeElement !== document.body) return; const anchorEl = document.getElementById(targetId); - if (!anchorEl) return; + if (!anchorEl) { + return; + } scrollContainer = ScrollParent(document.getElementById(targetId)) || document.documentElement; scrollContainerIsDocument = scrollContainer === document.documentElement; - if (!scrollContainer) return; + if (!scrollContainer) { + return; + } skipProgrammaticScrollEvent = true; @@ -86,7 +115,7 @@ export function scrollToTargetOnResize({ // Add scrollPosition as getBoundingClientRect is relative to viewport // Add the accumulated scroll offset to maintain relative position // subtract contentTop so it goes below sticky headers, rather than top of viewport - targetTop = anchorTop - contentTop() + currentScrollPosition + userScrollOffset; + targetTop = anchorTop - contentTopValue + currentScrollPosition + userScrollOffset; scrollContainer.scrollTo({ top: targetTop, @@ -97,18 +126,26 @@ export function scrollToTargetOnResize({ addScrollListener(); scrollListenerEnabled = true; } + + if (!intersectionObserver) { + targetElement = anchorEl; + setupIntersectionObserver(); + } + } + + function cleanup() { + ro.unobserve(containerEl); + containerEl.removeEventListener('ResizeUpdate', keepTargetAtTop); + removeScrollListener(); + + if (intersectionObserver) { + intersectionObserver.unobserve(targetElement); + intersectionObserver.disconnect(); + } } containerEl.addEventListener('ResizeUpdate', keepTargetAtTop); ro.observe(containerEl); - return function cleanup() { - // add a slight delay to this to allow for a final scroll to the - // element once notes have finished - setTimeout(() => { - ro.unobserve(containerEl); - containerEl.removeEventListener('ResizeUpdate', keepTargetAtTop); - removeScrollListener(); - }, 100); - }; + return cleanup; } diff --git a/spec/frontend/lib/utils/resize_observer_spec.js b/spec/frontend/lib/utils/resize_observer_spec.js index e2bbfeec5e60a421d09d61b1f3f2876c7279e74c..53e9866623959a03c92c0ca1f4b8e07db334ec3e 100644 --- a/spec/frontend/lib/utils/resize_observer_spec.js +++ b/spec/frontend/lib/utils/resize_observer_spec.js @@ -10,13 +10,14 @@ function mockStickyHeaderSize(val) { describe('ResizeObserver Utility', () => { let cleanup; + const mockHeaderSize = 90; const triggerResize = () => { const entry = document.querySelector('#content-body'); entry.dispatchEvent(new CustomEvent(`ResizeUpdate`, { detail: { entry } })); }; beforeEach(() => { - mockStickyHeaderSize(90); + mockStickyHeaderSize(mockHeaderSize); jest.spyOn(document.documentElement, 'scrollTo'); @@ -34,83 +35,114 @@ describe('ResizeObserver Utility', () => { resetHTMLFixture(); }); - describe('Observer behavior', () => { - it('returns null for empty target', () => { - cleanup = scrollToTargetOnResize({ - targetId: '', - container: '#content-body', - }); + it('returns null for empty target', () => { + cleanup = scrollToTargetOnResize({ + targetId: '', + container: '#content-body', + }); - expect(cleanup).toBe(null); + expect(cleanup).toBe(null); + }); + + it('does not scroll if target does not exist', () => { + scrollToTargetOnResize({ + targetId: 'some_imaginary_id', + container: '#content-body', }); - it('does not scroll if target does not exist', () => { - scrollToTargetOnResize({ - targetId: 'some_imaginary_id', + triggerResize(); + + expect(document.documentElement.scrollTo).not.toHaveBeenCalled(); + }); + + describe('with existing target', () => { + const topHeight = 110; + const scrollAmount = 160; + + beforeEach(() => { + cleanup = scrollToTargetOnResize({ + targetId: 'note_1234', container: '#content-body', }); + }); + + it('returns cleanup function', () => { + cleanup(); triggerResize(); expect(document.documentElement.scrollTo).not.toHaveBeenCalled(); }); - describe('with existing target', () => { - const cleanupTimeoutMs = 100; - const topHeight = 110; - const scrollAmount = 160; + it('scrolls body so anchor is just below sticky header (contentTop)', () => { + triggerResize(); - beforeEach(() => { - cleanup = scrollToTargetOnResize({ - targetId: 'note_1234', - container: '#content-body', - }); + expect(document.documentElement.scrollTo).toHaveBeenCalledWith({ + behavior: 'instant', + top: topHeight, }); + }); - it('returns cleanup function', () => { - cleanup(); + it('maintains scroll position relative to anchor after user scroll', () => { + // Initial scroll to anchor + triggerResize(); - jest.advanceTimersByTime(cleanupTimeoutMs); + // Simulate user scrolling down + window.scrollY = scrollAmount; + window.dispatchEvent(new Event('scroll')); - triggerResize(); + // Trigger resize again + triggerResize(); - expect(document.documentElement.scrollTo).not.toHaveBeenCalled(); + // Should maintain the 50px offset from original position + expect(document.documentElement.scrollTo).toHaveBeenCalledWith({ + top: topHeight + scrollAmount, + behavior: 'instant', }); + }); - it('scrolls body so anchor is just below sticky header (contentTop)', () => { - triggerResize(); + it('does not scroll if another element is focused', () => { + const anchorEl = document.getElementById('reply-field'); + anchorEl.focus(); - expect(document.documentElement.scrollTo).toHaveBeenCalledWith({ - behavior: 'instant', - top: topHeight, - }); - }); + triggerResize(); - it('maintains scroll position relative to anchor after user scroll', () => { - // Initial scroll to anchor - triggerResize(); + expect(document.documentElement.scrollTo).not.toHaveBeenCalled(); + }); - // Simulate user scrolling down - window.scrollY = scrollAmount; - window.dispatchEvent(new Event('scroll')); + describe('intersection observer', () => { + let intersectionCallback; + let mockIntersectionObserver; - // Trigger resize again + beforeEach(() => { + mockIntersectionObserver = jest.fn((callback) => { + intersectionCallback = callback; + return { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + }; + }); + + global.IntersectionObserver = mockIntersectionObserver; + }); + + it('sets up intersection observer after first scroll', () => { triggerResize(); - // Should maintain the 50px offset from original position - expect(document.documentElement.scrollTo).toHaveBeenCalledWith({ - top: topHeight + scrollAmount, - behavior: 'instant', + expect(mockIntersectionObserver).toHaveBeenCalled(); + expect(mockIntersectionObserver.mock.calls[0][1]).toEqual({ + root: null, }); }); - it('does not scroll if another element is focused', () => { - const anchorEl = document.getElementById('reply-field'); - anchorEl.focus(); - + it('cleans up when target is no longer visible', () => { triggerResize(); - expect(document.documentElement.scrollTo).not.toHaveBeenCalled(); + intersectionCallback([{ isIntersecting: false }]); + + triggerResize(); + expect(document.documentElement.scrollTo).toHaveBeenCalledTimes(1); }); }); });