diff --git a/src/embed/app.spec.ts b/src/embed/app.spec.ts index 1fdbf02c..f1296638 100644 --- a/src/embed/app.spec.ts +++ b/src/embed/app.spec.ts @@ -1720,6 +1720,36 @@ describe('App embed tests', () => { addEventListenerSpy.mockRestore(); }); + test('should listen to scroll and resize changes from scrollable iframe ancestors', async () => { + const scrollContainer = getRootEl(); + scrollContainer.style.overflow = 'auto'; + + const scrollContainerAddEventListenerSpy = jest.spyOn(scrollContainer, 'addEventListener'); + const resizeObserveSpy = jest.fn(); + const resizeDisconnectSpy = jest.fn(); + (window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: resizeObserveSpy, + disconnect: resizeDisconnectSpy, + })); + + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, + } as AppViewConfig); + + await appEmbed.render(); + + await executeAfterWait(() => { + expect(scrollContainerAddEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(resizeObserveSpy).toHaveBeenCalledWith(scrollContainer); + }, 100); + + appEmbed.destroy(); + expect(resizeDisconnectSpy).toHaveBeenCalled(); + }); + test('should remove window event listeners on destroy when fullHeight and lazyLoadingForFullHeight are enabled', async () => { const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); @@ -1734,7 +1764,7 @@ describe('App embed tests', () => { appEmbed.destroy(); expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); removeEventListenerSpy.mockRestore(); }); @@ -1965,5 +1995,3 @@ describe('AppEmbed visualOverrides tests', () => { await testVisualOverridesInEmbed(appEmbed, visualOverrides); }); }); - - diff --git a/src/embed/app.ts b/src/embed/app.ts index 82ee7668..c6b372c6 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -9,7 +9,7 @@ */ import { logger } from '../utils/logger'; -import { calculateVisibleElementData, getQueryParamString, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; +import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; import { Param, DOMSelector, @@ -656,6 +656,17 @@ export interface AppViewConfig extends AllEmbedViewConfig { * ``` */ lazyLoadingForFullHeight?: boolean; + /** + * This flag is used to enable container-aware full height lazy loading. + * + * Use this when the embed is rendered inside a scrollable or clipping + * container instead of relying on the browser window as the only viewport. + * + * @type {boolean} + * @default false + * @hidden + */ + enableScrollableContainerLazyLoading?: boolean; /** * The margin to be used for lazy loading. @@ -826,6 +837,10 @@ export class AppEmbed extends V1Embed { private defaultHeight = 500; + private lazyLoadScrollContainers: HTMLElement[] = []; + + private lazyLoadResizeObserver: ResizeObserver | undefined; + constructor(domSelector: DOMSelector, viewConfig: AppViewConfig) { viewConfig.embedComponentType = 'AppEmbed'; super(domSelector, viewConfig); @@ -1130,7 +1145,10 @@ export class AppEmbed extends V1Embed { } private sendFullHeightLazyLoadData = () => { - const data = calculateVisibleElementData(this.iFrame); + const data = calculateVisibleElementData( + this.iFrame, + this.viewConfig.enableScrollableContainerLazyLoading, + ); // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ this.trigger(HostEvent.VisibleEmbedCoordinates, data); @@ -1145,7 +1163,10 @@ export class AppEmbed extends V1Embed { */ private requestVisibleEmbedCoordinatesHandler = (data: MessagePayload, responder: any) => { logger.info('Sending RequestVisibleEmbedCoordinates', data); - const visibleCoordinatesData = calculateVisibleElementData(this.iFrame); + const visibleCoordinatesData = calculateVisibleElementData( + this.iFrame, + this.viewConfig.enableScrollableContainerLazyLoading, + ); responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); } @@ -1294,17 +1315,44 @@ export class AppEmbed extends V1Embed { } private registerLazyLoadEvents() { + if (!this.iFrame) { + return; + } if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { + this.unregisterLazyLoadEvents(); // TODO: Use passive: true, install modernizr to check for passive window.addEventListener('resize', this.sendFullHeightLazyLoadData); window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); + if (!this.viewConfig.enableScrollableContainerLazyLoading) { + return; + } + this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame); + this.lazyLoadScrollContainers.forEach((scrollContainer) => { + scrollContainer.addEventListener('scroll', this.sendFullHeightLazyLoadData); + }); + if (typeof ResizeObserver !== 'undefined') { + const resizeTargets = new Set([ + this.iFrame.parentElement, + ...getEffectiveClippingAncestors(this.iFrame), + ].filter(Boolean) as HTMLElement[]); + this.lazyLoadResizeObserver = new ResizeObserver(this.sendFullHeightLazyLoadData); + resizeTargets.forEach((resizeTarget) => { + this.lazyLoadResizeObserver.observe(resizeTarget); + }); + } } } private unregisterLazyLoadEvents() { if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { window.removeEventListener('resize', this.sendFullHeightLazyLoadData); - window.removeEventListener('scroll', this.sendFullHeightLazyLoadData); + window.removeEventListener('scroll', this.sendFullHeightLazyLoadData, true); + this.lazyLoadResizeObserver?.disconnect(); + this.lazyLoadResizeObserver = undefined; + this.lazyLoadScrollContainers.forEach((scrollContainer) => { + scrollContainer.removeEventListener('scroll', this.sendFullHeightLazyLoadData); + }); + this.lazyLoadScrollContainers = []; } } diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index d434271f..398104a4 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -1835,6 +1835,37 @@ describe('Liveboard/viz embed tests', () => { addEventListenerSpy.mockRestore(); }); + test('should listen to scroll and resize changes from scrollable iframe ancestors', async () => { + const scrollContainer = getRootEl(); + scrollContainer.style.overflow = 'auto'; + + const scrollContainerAddEventListenerSpy = jest.spyOn(scrollContainer, 'addEventListener'); + const resizeObserveSpy = jest.fn(); + const resizeDisconnectSpy = jest.fn(); + (window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: resizeObserveSpy, + disconnect: resizeDisconnectSpy, + })); + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, + } as LiveboardViewConfig); + + await liveboardEmbed.render(); + + await executeAfterWait(() => { + expect(scrollContainerAddEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(resizeObserveSpy).toHaveBeenCalledWith(scrollContainer); + }, 100); + + liveboardEmbed.destroy(); + expect(resizeDisconnectSpy).toHaveBeenCalled(); + }); + test('should remove window event listeners on destroy when fullHeight and lazyLoadingForFullHeight are enabled', async () => { const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); @@ -1850,7 +1881,7 @@ describe('Liveboard/viz embed tests', () => { liveboardEmbed.destroy(); expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything()); - expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything()); + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything(), true); removeEventListenerSpy.mockRestore(); }); diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index dadd45ba..3f8c6131 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -24,7 +24,7 @@ import { EmbedErrorCodes, ContextType, } from '../types'; -import { calculateVisibleElementData, getQueryParamString, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; +import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; import { getAuthPromise } from './base'; import { TsEmbed, V1Embed } from './ts-embed'; import { addPreviewStylesIfNotPresent } from '../utils/global-styles'; @@ -453,6 +453,16 @@ export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewC * ``` */ lazyLoadingForFullHeight?: boolean; + /** + * This flag is used to enable container-aware full height lazy loading. + * + * Use this when the embed is rendered inside a scrollable or clipping + * container instead of relying on the browser window as the only viewport. + * + * @type {boolean} + * @default false + */ + enableScrollableContainerLazyLoading?: boolean; /** * The margin to be used for lazy loading. * @@ -569,6 +579,10 @@ export class LiveboardEmbed extends V1Embed { private defaultHeight = 500; + private lazyLoadScrollContainers: HTMLElement[] = []; + + private lazyLoadResizeObserver: ResizeObserver | undefined; + constructor(domSelector: DOMSelector, viewConfig: LiveboardViewConfig) { viewConfig.embedComponentType = 'LiveboardEmbed'; @@ -825,7 +839,10 @@ export class LiveboardEmbed extends V1Embed { } private sendFullHeightLazyLoadData = () => { - const data = calculateVisibleElementData(this.iFrame); + const data = calculateVisibleElementData( + this.iFrame, + this.viewConfig.enableScrollableContainerLazyLoading, + ); // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ this.trigger(HostEvent.VisibleEmbedCoordinates, data); @@ -840,7 +857,10 @@ export class LiveboardEmbed extends V1Embed { */ private requestVisibleEmbedCoordinatesHandler = (data: MessagePayload, responder: any) => { logger.info('Sending RequestVisibleEmbedCoordinates', data); - const visibleCoordinatesData = calculateVisibleElementData(this.iFrame); + const visibleCoordinatesData = calculateVisibleElementData( + this.iFrame, + this.viewConfig.enableScrollableContainerLazyLoading, + ); responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); } @@ -1023,17 +1043,44 @@ export class LiveboardEmbed extends V1Embed { } private registerLazyLoadEvents() { + if(!this.iFrame) { + return; + } if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { + this.unregisterLazyLoadEvents(); // TODO: Use passive: true, install modernizr to check for passive window.addEventListener('resize', this.sendFullHeightLazyLoadData); window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); + if (!this.viewConfig.enableScrollableContainerLazyLoading) { + return; + } + this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame); + this.lazyLoadScrollContainers.forEach((scrollContainer) => { + scrollContainer.addEventListener('scroll', this.sendFullHeightLazyLoadData); + }); + if (typeof ResizeObserver !== 'undefined') { + const resizeTargets = new Set([ + this.iFrame.parentElement, + ...getEffectiveClippingAncestors(this.iFrame), + ].filter(Boolean) as HTMLElement[]); + this.lazyLoadResizeObserver = new ResizeObserver(this.sendFullHeightLazyLoadData); + resizeTargets.forEach((resizeTarget) => { + this.lazyLoadResizeObserver.observe(resizeTarget); + }); + } } } private unregisterLazyLoadEvents() { if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { window.removeEventListener('resize', this.sendFullHeightLazyLoadData); - window.removeEventListener('scroll', this.sendFullHeightLazyLoadData); + window.removeEventListener('scroll', this.sendFullHeightLazyLoadData, true); + this.lazyLoadResizeObserver?.disconnect(); + this.lazyLoadResizeObserver = undefined; + this.lazyLoadScrollContainers.forEach((scrollContainer) => { + scrollContainer.removeEventListener('scroll', this.sendFullHeightLazyLoadData); + }); + this.lazyLoadScrollContainers = []; } } diff --git a/src/utils.spec.ts b/src/utils.spec.ts index dd442eab..7eed6830 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -18,6 +18,9 @@ import { getTypeFromValue, arrayIncludesString, calculateVisibleElementData, + getClippingAncestors, + getEffectiveClippingAncestors, + getScrollableAncestors, formatTemplate, isValidCssMargin, resetValueFromWindow, @@ -519,7 +522,7 @@ describe('calculateVisibleElementData', () => { height: 200, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 0, // Not clipped from top @@ -529,6 +532,15 @@ describe('calculateVisibleElementData', () => { }); }); + it('should return zero dimensions when element is missing', () => { + expect(calculateVisibleElementData(null as unknown as HTMLElement)).toEqual({ + top: 0, + height: 0, + left: 0, + width: 0, + }); + }); + it('should calculate data for element clipped from top', () => { // Mock getBoundingClientRect for element partially above viewport jest.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ @@ -540,7 +552,7 @@ describe('calculateVisibleElementData', () => { height: 200, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 50, // Clipped 50px from top @@ -561,7 +573,7 @@ describe('calculateVisibleElementData', () => { height: 200, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 0, // Not clipped from top @@ -582,7 +594,7 @@ describe('calculateVisibleElementData', () => { height: 350, } as DOMRect); - const result = calculateVisibleElementData(mockElement); + const result = calculateVisibleElementData(mockElement, true); expect(result).toEqual({ top: 0, // Not clipped from top @@ -717,6 +729,190 @@ describe('calculateVisibleElementData', () => { width: 1200, // Full viewport width }); }); + + it('should calculate data clipped by a scrollable parent', () => { + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + scrollContainer.appendChild(mockElement); + + jest.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue({ + top: 100, + left: 50, + bottom: 600, + right: 650, + width: 600, + height: 500, + } as DOMRect); + + jest.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ + top: -100, + left: 50, + bottom: 900, + right: 650, + width: 600, + height: 1000, + } as DOMRect); + + const result = calculateVisibleElementData(mockElement, true); + + expect(result).toEqual({ + top: 200, + height: 500, + left: 0, + width: 600, + }); + }); + + it('should calculate data clipped by a non-scroll clipping parent', () => { + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + clippingContainer.appendChild(mockElement); + + jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue({ + top: 100, + left: 100, + bottom: 500, + right: 500, + width: 400, + height: 400, + } as DOMRect); + + jest.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ + top: 50, + left: 50, + bottom: 700, + right: 700, + width: 650, + height: 650, + } as DOMRect); + + const result = calculateVisibleElementData(mockElement, true); + + expect(result).toEqual({ + top: 50, + height: 400, + left: 50, + width: 400, + }); + }); +}); + +describe('getScrollableAncestors', () => { + it('should return an empty list when element is missing', () => { + expect(getScrollableAncestors(null as unknown as HTMLElement)).toEqual([]); + }); + + it('should find scrollable ancestors inside a shadow root', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + + const shadow = host.attachShadow({ mode: 'open' }); + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const embedTarget = document.createElement('div'); + const iframe = document.createElement('iframe'); + + shadow.appendChild(scrollContainer); + scrollContainer.appendChild(embedTarget); + embedTarget.appendChild(iframe); + + expect(getScrollableAncestors(iframe)).toEqual([scrollContainer]); + + host.remove(); + }); + + it('should ignore ancestors when computed style is unavailable', () => { + const parent = document.createElement('div'); + const iframe = document.createElement('iframe'); + parent.appendChild(iframe); + + const getComputedStyleSpy = jest + .spyOn(window, 'getComputedStyle') + .mockReturnValue(null as unknown as CSSStyleDeclaration); + + expect(getScrollableAncestors(iframe)).toEqual([]); + + getComputedStyleSpy.mockRestore(); + }); +}); + +describe('getClippingAncestors', () => { + it('should return an empty list when element is missing', () => { + expect(getClippingAncestors(null as unknown as HTMLElement)).toEqual([]); + }); + + it('should include scrollable and non-scroll clipping ancestors', () => { + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + const iframe = document.createElement('iframe'); + + scrollContainer.appendChild(clippingContainer); + clippingContainer.appendChild(iframe); + + expect(getClippingAncestors(iframe)).toEqual([clippingContainer, scrollContainer]); + }); +}); + +describe('getEffectiveClippingAncestors', () => { + it('should return an empty list when element is missing', () => { + expect(getEffectiveClippingAncestors(null as unknown as HTMLElement)).toEqual([]); + }); + + it('should ignore overflow ancestors that do not clip the element', () => { + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + const iframe = document.createElement('iframe'); + + clippingContainer.appendChild(iframe); + + jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue({ + top: 100, + left: 100, + bottom: 700, + right: 700, + width: 600, + height: 600, + } as DOMRect); + jest.spyOn(iframe, 'getBoundingClientRect').mockReturnValue({ + top: 200, + left: 200, + bottom: 400, + right: 400, + width: 200, + height: 200, + } as DOMRect); + + expect(getEffectiveClippingAncestors(iframe)).toEqual([]); + }); + + it('should include overflow ancestors that clip the element', () => { + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + const iframe = document.createElement('iframe'); + + clippingContainer.appendChild(iframe); + + jest.spyOn(clippingContainer, 'getBoundingClientRect').mockReturnValue({ + top: 100, + left: 100, + bottom: 500, + right: 500, + width: 400, + height: 400, + } as DOMRect); + jest.spyOn(iframe, 'getBoundingClientRect').mockReturnValue({ + top: 50, + left: 50, + bottom: 700, + right: 700, + width: 650, + height: 650, + } as DOMRect); + + expect(getEffectiveClippingAncestors(iframe)).toEqual([clippingContainer]); + }); }); describe('formatTemplate', () => { diff --git a/src/utils.ts b/src/utils.ts index eb12ebb3..7599f659 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -518,22 +518,141 @@ export const handleExitPresentMode = async (): Promise => { logger.warn('Exit fullscreen API is not supported by this browser.'); }; -export const calculateVisibleElementData = (element: HTMLElement) => { +const scrollableOverflowPattern = /(auto|scroll|overlay)/; +const clippingOverflowPattern = /(auto|scroll|overlay|hidden|clip)/; + +const getParentElementAcrossShadowRoot = (element: HTMLElement): HTMLElement | null => { + if (element.parentElement) { + return element.parentElement; + } + + const rootNode = element.getRootNode?.(); + if (typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot) { + return rootNode.host as HTMLElement; + } + + return null; +}; + +const hasMatchingOverflow = (element: HTMLElement, overflowPattern: RegExp) => { + const style = window.getComputedStyle(element); + return style ? overflowPattern.test(style.overflow + style.overflowX + style.overflowY) : false; +}; + +export const getScrollableAncestors = (element: HTMLElement) => { + const ancestors: HTMLElement[] = []; + if (!element) { + return ancestors; + } + let parent = getParentElementAcrossShadowRoot(element); + + while (parent && parent !== document.body && parent !== document.documentElement) { + if (hasMatchingOverflow(parent, scrollableOverflowPattern)) { + ancestors.push(parent); + } + parent = getParentElementAcrossShadowRoot(parent); + } + + return ancestors; +}; + +export const getClippingAncestors = (element: HTMLElement) => { + const ancestors: HTMLElement[] = []; + if (!element) { + return ancestors; + } + let parent = getParentElementAcrossShadowRoot(element); + + while (parent && parent !== document.body && parent !== document.documentElement) { + if (hasMatchingOverflow(parent, clippingOverflowPattern)) { + ancestors.push(parent); + } + parent = getParentElementAcrossShadowRoot(parent); + } + + return ancestors; +}; + +const getIntersectedRect = ( + rect: Pick, + clipRect: Pick, +) => ({ + top: Math.max(rect.top, clipRect.top), + left: Math.max(rect.left, clipRect.left), + bottom: Math.min(rect.bottom, clipRect.bottom), + right: Math.min(rect.right, clipRect.right), +}); + +const isSameVisibleRect = ( + rectA: ReturnType, + rectB: ReturnType, +) => rectA.top === rectB.top + && rectA.left === rectB.left + && rectA.bottom === rectB.bottom + && rectA.right === rectB.right; + +export const getEffectiveClippingAncestors = (element: HTMLElement) => { + if (!element) { + return []; + } + + const elementRect = element.getBoundingClientRect(); + let clipRect = { + top: 0, + left: 0, + bottom: window.innerHeight, + right: window.innerWidth, + }; + + return getClippingAncestors(element).filter((ancestor) => { + const currentVisibleRect = getIntersectedRect(elementRect, clipRect); + const nextClipRect = getIntersectedRect(clipRect, ancestor.getBoundingClientRect()); + const nextVisibleRect = getIntersectedRect(elementRect, nextClipRect); + const clipsElement = !isSameVisibleRect(currentVisibleRect, nextVisibleRect); + clipRect = nextClipRect; + return clipsElement; + }); +}; + +export const calculateVisibleElementData = ( + element: HTMLElement, + useClippingAncestors = false, +) => { + if (!element) { + return { + top: 0, + height: 0, + left: 0, + width: 0, + }; + } const rect = element.getBoundingClientRect(); - const windowHeight = window.innerHeight; - const windowWidth = window.innerWidth; + let clipTop = 0; + let clipLeft = 0; + let clipBottom = window.innerHeight; + let clipRight = window.innerWidth; + + if (useClippingAncestors) { + getEffectiveClippingAncestors(element).forEach((ancestor) => { + const ancestorRect = ancestor.getBoundingClientRect(); + clipTop = Math.max(clipTop, ancestorRect.top); + clipLeft = Math.max(clipLeft, ancestorRect.left); + clipBottom = Math.min(clipBottom, ancestorRect.bottom); + clipRight = Math.min(clipRight, ancestorRect.right); + }); + } - const frameRelativeTop = Math.max(rect.top, 0); - const frameRelativeLeft = Math.max(rect.left, 0); + const frameRelativeTop = Math.max(rect.top, clipTop); + const frameRelativeLeft = Math.max(rect.left, clipLeft); - const frameRelativeBottom = Math.min(windowHeight, rect.bottom); - const frameRelativeRight = Math.min(windowWidth, rect.right); + const frameRelativeBottom = Math.min(clipBottom, rect.bottom); + const frameRelativeRight = Math.min(clipRight, rect.right); const data = { - top: Math.max(0, rect.top * -1), + top: Math.max(0, clipTop - rect.top), height: Math.max(0, frameRelativeBottom - frameRelativeTop), - left: Math.max(0, rect.left * -1), + left: Math.max(0, clipLeft - rect.left), width: Math.max(0, frameRelativeRight - frameRelativeLeft), };