function calculateStyle(element: HTMLElement) {
return window.getComputedStyle?.(element)
}
function isOverflowed(overflow: string) {
return overflow === 'hidden' || overflow === 'scroll' || overflow === 'auto'
}
interface CalculateOptions {
/** Margin to consider the element visible by default is 0 */
offset?: number
/** Flag indicating that if the viewport is below the element, then we consider the element visible, default false */
compliantScrollDown?: boolean
}
interface Overflowed {
vertical: boolean
horizontal: boolean
}
interface CalculateBox {
left: number
top: number
right?: number
bottom?: number
width: number
height: number
rect: DOMRect
childOverflowed: Overflowed
isCompliantVerticalVisible?: boolean
isVisible: boolean
}
/**
* Calculates the area of the element that is visible on screen. Returns the calculated box.
*
* @param element DOM element for which to calculate the intersection
* @param options Options
* @param calculateOverflowed Calculate css overflow properties
*/
function calculateBox(
element: HTMLElement,
options: CalculateOptions,
calculateOverflowed = false
): CalculateBox {
const winHeight = window.innerHeight
const winWidth = window.innerWidth
const rect = element.getBoundingClientRect()
let {left, right, top, bottom} = rect
if (options.offset) {
left -= options.offset
right += options.offset
top -= options.offset
bottom += options.offset
}
top = Math.max(0, top)
left = Math.max(0, left)
top = Math.min(top, winHeight)
right = Math.min(right, winWidth)
const width = right - left
const height = bottom - top
const visibleWidth =
left < 0
? Math.min(Math.max(0, left + width), winWidth)
: Math.max(Math.min(winWidth - left, width), 0)
const visibleHeight =
top < 0
? Math.min(Math.max(0, top + height), winHeight)
: Math.max(Math.min(winHeight - top, height), 0)
let childOverflowed = {
horizontal: false,
vertical: false
}
if (calculateOverflowed) {
const {overflowX, overflowY} = calculateStyle(element)
childOverflowed = {
horizontal: isOverflowed(overflowX),
vertical: isOverflowed(overflowY)
}
}
const isCompliantVerticalVisible = options.compliantScrollDown
? bottom <= 0 && height > 0
: false
return {
left,
top,
rect,
childOverflowed,
isCompliantVerticalVisible,
width: visibleWidth,
height: visibleHeight,
isVisible:
visibleWidth > 0 && (visibleHeight > 0 || isCompliantVerticalVisible)
}
}
/**
* Calculates the intersection of a child box with a parent box. Returns the resulting child box.
*
* @param childBox Child box
* @param parentBox Parent box
*/
function calculateResultChildBox(
childBox: CalculateBox,
parentBox: CalculateBox
): CalculateBox {
if (
!parentBox.childOverflowed.horizontal &&
!parentBox.childOverflowed.vertical
) {
return childBox
}
const {childOverflowed, rect} = childBox
let {width, height, left, top, right, bottom} = childBox
if (parentBox.childOverflowed.vertical) {
top = Math.max(top, parentBox.top)
bottom = Math.min(top + height, parentBox.top + parentBox.height)
height = Math.max(bottom - top, 0)
}
if (parentBox.childOverflowed.horizontal) {
left = Math.max(left, parentBox.left)
right = Math.min(left + width, parentBox.left + parentBox.width)
width = Math.max(right - left, 0)
}
return {
left,
top,
width,
height,
childOverflowed,
rect,
isVisible:
childBox.isVisible &&
parentBox.isVisible &&
width > 0 &&
(height > 0 ||
(!!childBox.isCompliantVerticalVisible &&
!!parentBox.isCompliantVerticalVisible))
}
}
/** Visible area */
export interface VisibleArea {
/** Coordinates relative to the top left corner of the element from which it is visible */
top: number
/** Coordinates relative to the top left corner of the element from which it is visible */
left: number
/** Visible width of the element */
width: number
/** Visible height of the element */
height: number
}
interface VisibleOptions {
/** Determines if the element is visible on screen */
isVisible: boolean
/** Visible area of the DOM element */
area: VisibleArea
}
/**
* Determines the visible parts of the element.
*
* @param element DOM element
* @param options Options
*/
function calculateVisibleOptions(
element: HTMLElement,
options: CalculateOptions = {}
): VisibleOptions {
let currentBox = calculateBox(element, options)
let parent = element.parentElement
while (
parent &&
parent !== document.body &&
parent !== document.documentElement
) {
if (!currentBox.isVisible) {
break
}
currentBox = calculateResultChildBox(
currentBox,
calculateBox(parent, options, true)
)
parent = parent.parentElement
}
return {
isVisible: currentBox.isVisible,
area: {
left: currentBox.left - currentBox.rect.left,
top: currentBox.top - currentBox.rect.top,
width: currentBox.width,
height: currentBox.height
}
}
}
export function isVisible(
element: HTMLElement,
options: CalculateOptions = {}
) {
return calculateVisibleOptions(element, options).isVisible
}
export function getVisibleArea(
element: HTMLElement,
options: CalculateOptions = {}
) {
return calculateVisibleOptions(element, options).area
}