Skip to content
Snippets Groups Projects
toc.jsx 3.21 KiB
Newer Older
import { useState, useEffect } from "react";

import {
  createStyles,
  Box,
  Text,
  Group,
  rem,
  useMantineTheme,
} from "@mantine/core";

import { Icon, ICONS } from "vseth-canine-ui";

import { getAccentColor } from "../utilities/colors";

const useStyles = createStyles((theme) => ({
  link: {
    ...theme.fn.focusStyles(),
    display: "block",
    textDecoration: "none",
    color: theme.colorScheme === "dark" ? theme.colors.dark[0] : theme.black,
    lineHeight: 1.2,
    fontSize: theme.fontSizes.sm,
    padding: "5px",
    borderTopRightRadius: theme.radius.lg,
    borderBottomRightRadius: theme.radius.lg,
    borderLeft: `2px solid ${
      theme.colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[3]
    }`,

    "&:hover": {
      backgroundColor:
        theme.colorScheme === "dark"
          ? theme.colors.dark[6]
          : theme.colors.gray[0],
    },
  },

  linkActive: {
    fontWeight: 1000,
    borderLeftColor:
      theme.colors[theme.primaryColor][theme.colorScheme === "dark" ? 6 : 7],
    color:
      theme.colors[theme.primaryColor][theme.colorScheme === "dark" ? 2 : 7],

    "&, &:hover": {
      backgroundColor:
        theme.colorScheme === "dark"
          ? theme.fn.rgba(theme.colors[theme.primaryColor][9], 0.25)
          : theme.colors[theme.primaryColor][0],
    },
  },
}));

export default function TOC() {
  const [items, setItems] = useState([]);
  const [active, setActive] = useState("");
  const theme = useMantineTheme();
  const { classes, cx } = useStyles();

  const findClosestHeading = () => {
Alexander Schoch's avatar
Alexander Schoch committed
    const headings = document.querySelectorAll("h1, h2, h3");
    const currentPosition = window.scrollY;

    let closestHeading = null;
    let closestDistance = Infinity;

    headings.forEach((heading) => {
      const headingPosition =
        heading.getBoundingClientRect().top + window.scrollY;
      const distance = Math.abs(currentPosition - headingPosition);

      if (distance < closestDistance) {
        closestHeading = heading;
        closestDistance = distance;
      }
    });

    return closestHeading;
  };

  useEffect(() => {
    const headings = [...document.querySelectorAll("h1, h2, h3")];
    const links = headings.map((item) => ({
      link: "#" + item.id,
      label: item.innerText,
      order: Number(item.localName[item.localName.length - 1]),
    }));
    setItems(links);

    setActive("#" + findClosestHeading().id);
    document.addEventListener("scroll", function (e) {
      setActive("#" + findClosestHeading().id);
    });
  }, []);

  const entries = items.map((item) => (
    <Box
      component="a"
      href={item.link}
      key={item.label}
      className={cx(classes.link, {
        [classes.linkActive]: active === item.link,
      })}
      sx={(theme) => ({
        paddingLeft: `calc(${item.order} * ${theme.spacing.md}px)`,
      })}
    >
      {item.label}
    </Box>
  ));

  return (
    <div
      style={{
        position: "sticky",
        top: "2rem",
        maxHeight: "calc(100vh - 2rem)",
        overflowY: "auto",
        marginBottom: "20px",
      }}
    >
      <Group mb="md">
        <Icon icon={ICONS.LIST} color={getAccentColor(theme)} />
        <Text>Table of contents</Text>
      </Group>
      {entries}
    </div>
  );
}