diff --git a/frontend/.env.development b/frontend/.env.development index 09b87ea0..5cb8229c 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -20,4 +20,5 @@ CODEGEN_STOREFRONT_SHOP_NAME="ifixit-test" # Flags NEXT_PUBLIC_FLAG__PRODUCT_PAGE_ENABLED=true -NEXT_PUBLIC_FLAG__STORE_HOME_PAGE_ENABLED=true \ No newline at end of file +NEXT_PUBLIC_FLAG__STORE_HOME_PAGE_ENABLED=true +NEXT_PUBLIC_FLAG__TROUBLESHOOTING_TOC_ENABLED=true \ No newline at end of file diff --git a/frontend/.env.production b/frontend/.env.production index bedcc2c2..e4f235ad 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -25,4 +25,6 @@ NEXT_PUBLIC_GTAG_ID=G-5ZXNWJ73GK # Overridden in prod NEXT_PUBLIC_ALGOLIA_PRODUCT_INDEX_NAME=dev_product_group_en NEXT_PUBLIC_DEFAULT_STORE_CODE=us -NEXT_PUBLIC_POLYFILL_DOMAIN=https://www.ifixit.com \ No newline at end of file +NEXT_PUBLIC_POLYFILL_DOMAIN=https://www.ifixit.com +# flags +NEXT_PUBLIC_FLAG__TROUBLESHOOTING_TOC_ENABLED=false \ No newline at end of file diff --git a/frontend/.env.test b/frontend/.env.test index cc60678a..529b4057 100644 --- a/frontend/.env.test +++ b/frontend/.env.test @@ -7,5 +7,6 @@ STRAPI_IMAGE_DOMAIN=ifixit-dev-strapi-uploads.s3.us-west-1.amazonaws.com NEXT_PUBLIC_ALGOLIA_PRODUCT_INDEX_NAME=dev_product_group_en NEXT_PUBLIC_DEFAULT_STORE_CODE=test NEXT_PUBLIC_FLAG__PRODUCT_PAGE_ENABLED=true +NEXT_PUBLIC_FLAG__TROUBLESHOOTING_TOC_ENABLED=false NEXT_PUBLIC_MATOMO_URL=https://matomo.ubreakit.com NODE_OPTIONS="--dns-result-order ipv4first" \ No newline at end of file diff --git a/frontend/components/common/FlexScrollGradient.tsx b/frontend/components/common/FlexScrollGradient.tsx new file mode 100644 index 00000000..a88ec26f --- /dev/null +++ b/frontend/components/common/FlexScrollGradient.tsx @@ -0,0 +1,243 @@ +import { + Flex, + FlexProps, + SystemStyleObject, + useBreakpointValue, + useMergeRefs, +} from '@chakra-ui/react'; +import { useRef, useLayoutEffect, forwardRef, useState } from 'react'; + +type InnerFlexStyling = { + _after: SystemStyleObject; + _before: SystemStyleObject; + overflowX?: FlexProps['overflowX']; + overflowY?: FlexProps['overflowY']; +}; + +const gradientWidths = { base: 40, sm: 50, md: 70, lg: 100 }; + +const sharedStyle: SystemStyleObject = { + content: '""', + position: 'absolute', + height: '100%', + pointerEvents: 'none', + zIndex: 9999, +}; + +function easeInOutQuad(x: number): number { + return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; +} + +enum FlexScrollDirection { + ROW, + COLUMN, +} + +function getScrollDirection(el: HTMLElement): FlexScrollDirection { + const { flexDirection } = window.getComputedStyle(el); + if (flexDirection === 'column') { + return FlexScrollDirection.COLUMN; + } else { + return FlexScrollDirection.ROW; + } +} + +function getGradientsBaseStyle( + scrollDirection: FlexScrollDirection, + gradientSizePX: number, + color = '#f9fafb' +) { + const beforeStart = + scrollDirection === FlexScrollDirection.ROW ? 'left' : 'top'; + const afterStart = + scrollDirection === FlexScrollDirection.ROW ? 'right' : 'bottom'; + + const beforeBackgroundDegrees = + scrollDirection === FlexScrollDirection.ROW ? 270 : 0; + const afterBackgroundDegrees = + scrollDirection === FlexScrollDirection.ROW ? 90 : 180; + + const gradientSizeProp = + scrollDirection === FlexScrollDirection.ROW ? 'width' : 'height'; + + return { + before: { + ...sharedStyle, + [gradientSizeProp]: `${gradientSizePX}px`, + [beforeStart]: '0px', + background: `linear-gradient(${beforeBackgroundDegrees}deg, rgba(249, 250, 251, 0) 0%, ${color} 106.41%)`, + }, + after: { + ...sharedStyle, + [gradientSizeProp]: `${gradientSizePX}px`, + [afterStart]: '0px', + background: `linear-gradient(${afterBackgroundDegrees}deg, rgba(249, 250, 251, 0) 0%, ${color} 106.41%)`, + }, + }; +} + +function getGradientStyleProps( + el: HTMLElement, + gradientSizePX: number +): InnerFlexStyling { + const flexScrollDirection = getScrollDirection(el); + const { + // Used for both calcs + clientWidth, + clientHeight, + // Used for ROWs + scrollWidth, + scrollLeft, + // Used for COLUMNs + scrollHeight, + scrollTop, + } = el; + + const isScrollable = + flexScrollDirection === FlexScrollDirection.ROW + ? scrollWidth > clientWidth + : scrollHeight > clientHeight; + + if (!isScrollable) { + return { + _before: { opacity: 0, height: 0 }, + _after: { opacity: 0, height: 0 }, + }; + } + + const start = + flexScrollDirection === FlexScrollDirection.ROW ? scrollLeft : scrollTop; + const windowSpace = + flexScrollDirection === FlexScrollDirection.ROW + ? clientWidth + : clientHeight; + const gradientSpace = + flexScrollDirection === FlexScrollDirection.ROW + ? clientHeight + : clientWidth; + const scrollSpace = + flexScrollDirection === FlexScrollDirection.ROW + ? scrollWidth + : scrollHeight; + const sizeProp = + flexScrollDirection === FlexScrollDirection.ROW ? 'height' : 'width'; + + const scrollPosition = start + windowSpace; + const afterStart = scrollSpace - gradientSizePX; + + const size = `${gradientSpace}px`; + + const beforeGradientShownPercent = + start < gradientSizePX ? start / gradientSizePX : 1; + + const afterGradientShownPercent = + scrollPosition >= afterStart + ? 1 - (scrollPosition - afterStart) / gradientSizePX + : 1; + + const beforeGradientOpacity = easeInOutQuad( + Math.max(0, beforeGradientShownPercent) + ); + + const afterGradientOpacity = easeInOutQuad( + Math.max(0, afterGradientShownPercent) + ); + + const { before, after } = getGradientsBaseStyle( + flexScrollDirection, + gradientSizePX + ); + + const { borderRadius } = window.getComputedStyle(el); + + const overflowX = + flexScrollDirection === FlexScrollDirection.ROW ? 'auto' : undefined; + const overflowY = + flexScrollDirection === FlexScrollDirection.ROW ? undefined : 'auto'; + + return { + _before: { + ...before, + opacity: beforeGradientOpacity, + [sizeProp]: size, + borderRadius, + }, + _after: { + ...after, + opacity: afterGradientOpacity, + [sizeProp]: size, + borderRadius, + }, + overflowX, + overflowY, + }; +} + +export const FlexScrollGradient = forwardRef(function FlexScrollGradient( + { + nestedFlexProps, + ...props + }: FlexProps & { + nestedFlexProps?: FlexProps; + }, + ref +) { + const internalRef = useRef(); + const refs = useMergeRefs(internalRef, ref); + + const [flexScrollState, setFlexScrollState] = useState({ + _before: { opacity: 0 }, + _after: { opacity: 0 }, + }); + + const gradientSizePX = useBreakpointValue(gradientWidths) as number; + + useLayoutEffect(() => { + const el = internalRef.current; + if (!el) { + return; + } + + const doMeasure = (el: HTMLElement) => { + const state = getGradientStyleProps(el, gradientSizePX); + setFlexScrollState(() => state); + }; + + const measure = () => { + doMeasure(el); + }; + + measure(); + + el.addEventListener('transitionend', measure, { passive: true }); + el.addEventListener('scroll', measure, { passive: true }); + window.addEventListener('resize', measure, { passive: true }); + + return () => { + el.removeEventListener('transitionend', measure); + el.removeEventListener('scroll', measure); + window.removeEventListener('resize', measure); + }; + }, [internalRef, gradientSizePX]); + + if (props.position) { + throw new Error( + 'FlexScrollGradient: position prop is not allowed. To get the gradient working we must use position relative. Wrap your component in another layer to handle positioning.' + ); + } + + return ( + + + {props.children} + + + ); +}); diff --git a/frontend/config/flags.ts b/frontend/config/flags.ts index 92e76f5b..ae14b878 100644 --- a/frontend/config/flags.ts +++ b/frontend/config/flags.ts @@ -3,4 +3,6 @@ export const flags = { process.env.NEXT_PUBLIC_FLAG__PRODUCT_PAGE_ENABLED === 'true', STORE_HOME_PAGE_ENABLED: process.env.NEXT_PUBLIC_FLAG__STORE_HOME_PAGE_ENABLED === 'true', + TROUBLESHOOTING_TOC_ENABLED: + process.env.NEXT_PUBLIC_FLAG__TROUBLESHOOTING_TOC_ENABLED === 'true', }; diff --git a/frontend/templates/troubleshooting/components/HeadingSelfLink.tsx b/frontend/templates/troubleshooting/components/HeadingSelfLink.tsx index 4954d73e..a0f19bd8 100644 --- a/frontend/templates/troubleshooting/components/HeadingSelfLink.tsx +++ b/frontend/templates/troubleshooting/components/HeadingSelfLink.tsx @@ -26,7 +26,7 @@ export const HeadingSelfLink = forwardRef< id, })} {...props} - {...ref} + ref={ref} > {children} conclusion.heading !== 'Related Pages' + ); + + const sections = wikiData.introduction + .concat(wikiData.solutions) + .concat(filteredConclusions); + + const sectionTitles = sections + .map((section) => section.heading) + .concat(RelatedProblemsTitle) + .filter(Boolean); + return ( <> @@ -95,125 +124,153 @@ const Wiki: NextPageWithLayout<{ devicePartsUrl={wikiData.devicePartsUrl} breadcrumbs={wikiData.breadcrumbs} /> - - + + - - - - - {title} + - - - - - {title} - - - - - {title} - - - - - - {wikiData.title} - - - - - {wikiData.introduction.map((intro) => ( - - ))} - {wikiData.solutions.length > 0 && ( - + - - {'Causes'} - - - - {wikiData.solutions.map((solution, index) => ( - - ))} - - - )} - - - {wikiData.linkedProblems.length > 0 && ( - - )} - - - + + + + + {title} + + + + + {title} + + + + + + {wikiData.title} + + + + + {wikiData.introduction.map((intro) => ( + + ))} + {wikiData.solutions.length > 0 && ( + + + {'Causes'} + + + + {wikiData.solutions.map((solution, index) => ( + + ))} + + + )} + + + {wikiData.linkedProblems.length > 0 && ( + + )} + + + + {viewStats && } ); @@ -686,6 +743,7 @@ function AuthorListing({ } function IntroductionSection({ intro }: { intro: Section }) { + const { ref } = LinkToTOC(intro.heading); return ( <> {intro.heading && ( @@ -694,6 +752,7 @@ function IntroductionSection({ intro }: { intro: Section }) { fontWeight="semibold" selfLinked id={intro.id} + ref={ref} > {intro.heading} @@ -703,24 +762,26 @@ function IntroductionSection({ intro }: { intro: Section }) { ); } -function ConclusionSection({ conclusion }: { conclusion: Section }) { +const ConclusionSection = function ConclusionSectionInner({ + conclusion, +}: { + conclusion: Section; +}) { + const { ref } = LinkToTOC(conclusion.heading); return ( <> - + {conclusion.heading} ); -} +}; function Conclusion({ conclusion: conclusions }: { conclusion: Section[] }) { - const filteredConclusions = conclusions.filter( - (conclusion) => conclusion.heading !== 'Related Pages' - ); return ( <> - {filteredConclusions.map((conclusion) => ( + {conclusions.map((conclusion) => ( (RelatedProblemsTitle); return ( <> - Related Problems + {RelatedProblemsTitle} {problems.map((problem) => ( diff --git a/frontend/templates/troubleshooting/scrollPercent.tsx b/frontend/templates/troubleshooting/scrollPercent.tsx new file mode 100644 index 00000000..b1a483e9 --- /dev/null +++ b/frontend/templates/troubleshooting/scrollPercent.tsx @@ -0,0 +1,115 @@ +import { Flex, useToken, css, useTheme } from '@chakra-ui/react'; +import { useEffect, useState, RefObject } from 'react'; + +export type ScrollPercentProps = { + scrollContainerRef?: RefObject; + hideOnZero?: boolean; + hideOnScrollPast?: boolean; +}; + +export function ScrollPercent({ + scrollContainerRef, + hideOnZero = false, + hideOnScrollPast = false, +}: ScrollPercentProps) { + const [scrollPercent, setScrollPercent] = useState(0); + const [scrolledPast, setScrolledPast] = useState(false); + + const getUpdateScrollPercent = (container: HTMLElement) => { + return () => { + const atContainer = container.offsetTop < window.scrollY; + if (!atContainer) { + setScrollPercent(0); + return; + } + + const scrollPercent = Math.min( + 1, + (window.scrollY - container.offsetTop) / + (container.offsetHeight - window.innerHeight) + ); + setScrollPercent(scrollPercent); + setScrolledPast( + scrollPercent === 1 && + window.scrollY > container.offsetTop + container.offsetHeight + ); + }; + }; + + useEffect(() => { + const el = scrollContainerRef?.current || document.documentElement; + const handler = getUpdateScrollPercent(el); + window.addEventListener('scroll', handler); + window.addEventListener('resize', handler); + return () => { + window.removeEventListener('scroll', handler); + window.removeEventListener('resize', handler); + }; + }, [scrollContainerRef]); + + const height = useScrollPercentHeight(CssTokenOption.CssString); + const [blue200, blue500] = useToken('colors', ['blue.200', 'blue.500']); + + const containerTop = scrollContainerRef?.current?.offsetTop; + const hasReachedScrollContainer = + containerTop && window.scrollY > containerTop; + if (hideOnZero && !hasReachedScrollContainer) { + return null; + } + + if (hideOnScrollPast && scrolledPast) { + return null; + } + + return ( + + ); +} + +export enum CssTokenOption { + ThemeToken = 'ThemeToken', + CssString = 'CssString', + Number = 'Number', +} + +export function useScrollPercentHeight( + option: CssTokenOption.ThemeToken +): number; +export function useScrollPercentHeight( + option: CssTokenOption.CssString +): string; +export function useScrollPercentHeight(option: CssTokenOption.Number): number; + +export function useScrollPercentHeight( + option: CssTokenOption +): number | string { + const space2 = useToken('space', 2); + const theme = useTheme(); + if (option === CssTokenOption.ThemeToken) { + return space2; + } + const cssThunk = css({ + height: space2, + }); + + const heightStr = cssThunk(theme).height as string; + if (option === CssTokenOption.CssString) { + return heightStr; + } + + const height = parseInt(heightStr, 10); + return height; +} diff --git a/frontend/templates/troubleshooting/solution.tsx b/frontend/templates/troubleshooting/solution.tsx index 8286576d..821e3a16 100644 --- a/frontend/templates/troubleshooting/solution.tsx +++ b/frontend/templates/troubleshooting/solution.tsx @@ -25,6 +25,7 @@ import { import Prerendered from './prerendered'; import { GuideResource, ProductResource } from './Resource'; import { HeadingSelfLink } from './components/HeadingSelfLink'; +import { LinkToTOC } from './tocContext'; const _SolutionFooter = () => ( (solution.heading); return ( + + + + ); +} + +function LargeTOC({ + items, + listItemProps, + ...props +}: FlexProps & { listItemProps?: ListItemProps; items: TOCRecord[] }) { + return ( + + + + ); +} + +export function MobileTOC({ + listItemProps, + display, + ...props +}: FlexProps & { listItemProps?: ListItemProps }) { + const { getItems } = useTOCContext(); + const items = getItems(); + const activeItem = items.find((item) => item.active); + const scrollIndicatorHeightCSS = useScrollPercentHeight( + CssTokenOption.CssString + ); + const actualDisplay = activeItem ? display : 'none'; + const { isOpen, onOpen, onClose } = useDisclosure(); + + useEffect(() => { + if (actualDisplay === 'none') { + onClose(); + } + }, [actualDisplay, onClose]); + + const title = activeItem?.title ?? 'Table of Contents'; + + return ( + + + } + color="gray.900" + fontWeight={510} + fontSize="sm" + borderBottom="1px solid" + borderColor="gray.300" + background="white" + borderRadius={0} + paddingLeft={4} + paddingRight={4} + _active={{ background: 'white' }} + > + {title} + + + + + + + ); +} + +function MobileTOCItems({ items }: { items: TOCRecord[] }) { + return ( + <> + {items.map((item, index) => { + return ; + })} + + ); +} + +function MobileTOCItem({ title, scrollTo, elementRef, active }: TOCRecord) { + const ref = useRef(null); + + const scrollIndicatorHeight = useScrollPercentHeight(CssTokenOption.Number); + const blue100 = useToken('colors', 'blue.100'); + + useScrollToActiveEffect(ref, active); + const onClick = () => { + const el = elementRef.current; + + if (!el) { + return; + } + + scrollTo({ + bufferPx: scrollIndicatorHeight, + }); + + highlightEl(el, blue100); + }; + + return ( + + {title} + + ); +} + +function TOCItems({ + tocItems, + listItemProps, +}: { + tocItems: TOCRecord[]; + listItemProps?: ListItemProps; +}) { + const items = tocItems.map((props, index) => { + return ; + }); + + return <>{items}; +} + +function useScrollToActiveEffect(ref: RefObject, active: boolean) { + useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + + if (!active) { + return; + } + + el.parentElement?.scrollTo({ + top: el.offsetTop - el.parentElement.clientHeight / 2, + }); + }, [ref, active]); +} + +function TOCItem({ + title, + elementRef, + active, + scrollTo, + ...props +}: TOCRecord & ListItemProps) { + const scrollIndicatorHeight = useScrollPercentHeight(CssTokenOption.Number); + + const ref = useRef(null); + + const blue100 = useToken('colors', 'blue.100'); + + const onClick = () => { + const el = elementRef.current; + if (!el) { + return; + } + + scrollTo({ + bufferPx: scrollIndicatorHeight, + }); + + highlightEl(el, blue100); + }; + + useScrollToActiveEffect(ref, active); + + return ( + + + {title} + + + ); +} + +function highlightEl(el: HTMLElement, color: string) { + const originalBackgroundColor = el.style.backgroundColor; + const originalTransition = el.style.transition; + + el.style.transition = 'background-color .5s ease-in-out'; + el.style.backgroundColor = color; + + setTimeout(() => { + el.style.backgroundColor = originalBackgroundColor; + }, 500); + setTimeout(() => { + el.style.transition = originalTransition; + }, 1000); +} + +export function onlyShowIfTOCFlagEnabled

( + Component: React.ComponentType> +) { + return (props: PropsWithChildren

) => { + if (!flags.TROUBLESHOOTING_TOC_ENABLED) { + return <>{props.children}; + } + + return ; + }; +} + +export function onlyShowIfTOCFlagEnabledProvider( + ExistingContext: typeof TOCContextProvider +) { + return (props: TOCContextProviderProps) => { + if (!flags.TROUBLESHOOTING_TOC_ENABLED) { + const context = { + addItem: () => {}, + updateItemRef: () => {}, + removeItem: () => {}, + getItems: () => [], + }; + return ( + + {props.children} + + ); + } + + return ; + }; +} diff --git a/frontend/templates/troubleshooting/tocContext.tsx b/frontend/templates/troubleshooting/tocContext.tsx new file mode 100644 index 00000000..491f51c3 --- /dev/null +++ b/frontend/templates/troubleshooting/tocContext.tsx @@ -0,0 +1,320 @@ +import { + useEffect, + useState, + RefObject, + useContext, + createContext, + PropsWithChildren, + useRef, + useCallback, + Dispatch, + SetStateAction, +} from 'react'; + +export type TOCRecord = { + title: string; + elementRef: RefObject; + active: boolean; + scrollTo: (scrollToOptions?: ScrollToOptions) => void; +}; + +export type ScrollToOptions = { + bufferPx?: number; + addIdToUrl?: boolean; +}; + +export type TOCItems = Record; + +export type TOCContext = { + addItem: (title: string, ref: RefObject) => void; + updateItemRef: (title: string, ref: RefObject) => void; + removeItem: (title: string) => void; + getItems: () => TOCRecord[]; +}; + +export const TOCContext = createContext(null); + +function scrollTo( + ref: RefObject, + scrollToOptions?: ScrollToOptions +) { + const el = ref.current; + if (!el) { + return; + } + + const scrollHeight = document.body.scrollHeight; + const scrollTop = + (el.offsetTop / scrollHeight) * (scrollHeight - window.innerHeight); + + const bufferPx = scrollToOptions?.bufferPx || 0; + const scrollTo = scrollTop + bufferPx; + + window.scrollTo({ top: scrollTo, behavior: 'smooth' }); + + const addIdToUrl = scrollToOptions?.addIdToUrl || true; + const id = el.id; + + if (addIdToUrl && id) { + window.history.pushState(null, '', `#${id}`); + } +} + +function createRecord(title: string, ref?: RefObject) { + const elementRef = ref || { current: null }; + return { + title, + elementRef: elementRef, + active: false, + scrollTo: (scrollToOptions?: ScrollToOptions) => + scrollTo(elementRef, scrollToOptions), + }; +} + +function createTOCItems(titles: string[]) { + const records = titles.map((title) => + createRecord(title, { current: null }) + ); + return Object.fromEntries(records.map((record) => [record.title, record])); +} + +function updateTOCItemRef( + existingItems: TOCItems, + title: string, + ref?: RefObject +) { + const existingItem = existingItems[title]; + + if (!existingItem) { + console.error(`No item with title ${title} exists in the TOC`); + return existingItems; + } + + const newItems = { ...existingItems }; + const newRef = ref || existingItem.elementRef; + newItems[title] = { + ...existingItem, + elementRef: newRef, + scrollTo: (scrollToOptions?: ScrollToOptions) => + scrollTo(newRef, scrollToOptions), + }; + return newItems; +} + +function removeTOCItem(existingItems: TOCItems, title: string) { + const newItems = { ...existingItems }; + delete newItems[title]; + return newItems; +} + +function useCRUDFunctions( + items: TOCItems, + setItems: Dispatch> +) { + const updateItemRef = useCallback( + (title: string, ref: RefObject) => { + setItems((items) => updateTOCItemRef(items, title, ref)); + }, + [setItems] + ); + + const addItem = useCallback( + (title: string, ref: RefObject) => { + setItems((items) => { + const titleExists = Object.keys(items).includes(title); + + if (titleExists) { + throw new Error(`Title ${title} already exists in the TOC`); + } + + const newItems = { ...items }; + newItems[title] = createRecord(title, ref); + return newItems; + }); + }, + [setItems] + ); + + const getItems = useCallback(() => { + return sortVertically(Object.values(items)); + }, [items]); + + const removeItem = useCallback( + (title: string) => { + setItems((items) => removeTOCItem(items, title)); + }, + [setItems] + ); + + return { + addItem, + updateItemRef, + getItems, + removeItem, + }; +} + +function useObserveItems( + items: TOCItems, + setItems: Dispatch> +) { + const updateClosetItem = useCallback(() => { + setItems((items) => { + const closest = getClosest(items); + const newItems = { ...items }; + Object.values(newItems).forEach((newItem) => { + newItem.active = newItem.title === closest?.title; + }); + return newItems; + }); + }, [setItems]); + + // watch for elements entering / leaving the viewport and update the active element + useEffect(() => { + // Update active item on scroll + const scrollHandler = () => { + updateClosetItem(); + }; + + // Update active item on resize + const resizeHandler = () => { + updateClosetItem(); + }; + window.addEventListener('scroll', scrollHandler, { passive: true }); + window.addEventListener('resize', resizeHandler, { passive: true }); + + return () => { + window.removeEventListener('scroll', scrollHandler); + window.removeEventListener('resize', resizeHandler); + }; + }, [updateClosetItem]); + + // Update the active element on nextjs hydration + useEffect(() => { + const observer = new MutationObserver(() => { + updateClosetItem(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + }; + }, [updateClosetItem]); +} + +export type TOCContextProviderProps = PropsWithChildren<{ + defaultTitles?: string[]; +}>; + +export const TOCContextProvider = ({ + children, + defaultTitles, +}: TOCContextProviderProps) => { + const [items, setItems] = useState( + createTOCItems(defaultTitles || []) + ); + + const { addItem, updateItemRef, getItems, removeItem } = useCRUDFunctions( + items, + setItems + ); + useObserveItems(items, setItems); + + const context = { + addItem, + updateItemRef, + removeItem, + getItems, + }; + return {children}; +}; + +export const useTOCContext = () => { + const context = useContext(TOCContext); + if (!context) { + throw new Error('useTOCContext must be used within a TOCContext'); + } + return context; +}; + +export function AddToTOCClientSide(title?: string) { + const { addItem, removeItem } = useTOCContext(); + const ref = useRef(null); + + useEffect(() => { + if (!title) { + return; + } + addItem(title, ref); + + return () => { + if (!title) { + return; + } + removeItem(title); + }; + }, [title, ref, addItem, removeItem]); + return { ref }; +} + +export function LinkToTOC(title?: string) { + const { updateItemRef, removeItem } = useTOCContext(); + const ref = useRef(null); + + useEffect(() => { + if (!title) { + return; + } + updateItemRef(title, ref); + + return () => { + if (!title) { + return; + } + removeItem(title); + }; + }, [title, ref, updateItemRef, removeItem]); + return { ref }; +} + +function sortVertically(records: TOCRecord[]): TOCRecord[] { + return records.sort((a, b) => { + const aTop = a.elementRef.current?.offsetTop || 0; + const bTop = b.elementRef.current?.offsetTop || 0; + return aTop - bTop; + }); +} + +function getClosest(items: TOCItems) { + const visibleItems = Object.values(items).filter((record) => { + const el = record.elementRef.current; + if (!el) { + return false; + } + + const elBottomScrollPastTopOfViewport = + el.offsetTop + el.clientHeight <= window.scrollY; + + const isVisible = !elBottomScrollPastTopOfViewport; + + return isVisible; + }); + const verticallySortedItems = sortVertically(visibleItems); + + const scrollHeight = document.body.scrollHeight; + const scrollPercent = window.scrollY / (scrollHeight - window.innerHeight); + + const closest = verticallySortedItems.reverse().find((record) => { + const itemTop = record.elementRef.current?.offsetTop || 0; + + const itemPercent = itemTop / scrollHeight; + const hasPassed = scrollPercent >= itemPercent; + return hasPassed; + }); + + return closest || null; +}