diff --git a/package-lock.json b/package-lock.json index ba216ebc..99ec5c4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@r4ai/remark-callout": "^0.6.2", "classnames": "^2.5.1", + "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "klaw-sync": "^6.0.0", "lucide-react": "^0.424.0", @@ -4449,6 +4450,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", diff --git a/package.json b/package.json index bbb87f4e..70018137 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@r4ai/remark-callout": "^0.6.2", "classnames": "^2.5.1", + "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "klaw-sync": "^6.0.0", "lucide-react": "^0.424.0", diff --git a/src/components/sidecar/Sidecar.module.css b/src/components/sidecar/Sidecar.module.css index d1a40b03..7b6b0e39 100644 --- a/src/components/sidecar/Sidecar.module.css +++ b/src/components/sidecar/Sidecar.module.css @@ -1,69 +1,99 @@ -.sidecar { - --left-padding: 12px; - --base-depth: 2; /* H1's & H2's will have standard padding */ - --depth-padding: 8px; /* Additional padding for each header number above H2 */ - --gradient-height: 20px; - --gradient-color: var(--gray-0); +.sidecarWrapper { + position: relative; + display: flex; + flex-direction: column; - &::before, - &::after { - content: ""; - position: sticky; - display: block; - left: 0; - right: 0; - height: var(--gradient-height); - pointer-events: none; - opacity: 1; - transition: opacity 0.3s ease; - z-index: 1; - } + .sidecar { + --left-padding: 12px; + --base-depth: 2; /* H1's & H2's will have standard padding */ + --depth-padding: 8px; /* Additional padding for each header number above H2 */ + --gradient-height: 20px; + --gradient-color: var(--gray-0); - &::before { - top: 0; - background: linear-gradient(to bottom, var(--gradient-color), transparent); - } + margin: 0 !important; - &::after { - bottom: 0; - background: linear-gradient(to top, var(--gradient-color), transparent); - } + &::before, + &::after { + content: ""; + position: sticky; + display: block; + left: 0; + right: 0; + height: var(--gradient-height); + pointer-events: none; + opacity: 1; + transition: opacity 0.3s ease; + z-index: 1; + } - & ul { - list-style: none; - position: relative; - z-index: 0; - & li { - --item-color: var(--gray-4); - &:hover { - --item-color: var(--gray-6); - } - &.active { - --item-color: var(--gray-8); - } - border-left: 2px solid var(--item-color); - padding: 2px 0; - padding-left: calc( - var(--left-padding) + - ( - (max(var(--depth), var(--base-depth)) - (var(--base-depth) - 1)) * - var(--depth-padding) - ) + &::before { + top: 0; + background: linear-gradient( + to bottom, + var(--gradient-color), + transparent ); - & p { - color: var(--item-color); - overflow-wrap: break-word; - } - & a { - text-decoration-line: none; - text-decoration-color: var(--item-color); + } + + &::after { + bottom: 0; + background: linear-gradient(to top, var(--gradient-color), transparent); + } + + & ul { + list-style: none; + position: relative; + z-index: 0; + & li { + --item-color: var(--gray-4); &:hover { + --item-color: var(--gray-6); + } + &.active { + --item-color: var(--gray-8); + } + border-left: 2px solid var(--item-color); + padding: 2px 0; + padding-left: calc( + var(--left-padding) + + ( + (max(var(--depth), var(--base-depth)) - (var(--base-depth) - 1)) * + var(--depth-padding) + ) + ); + & p { + color: var(--item-color); + overflow-wrap: break-word; + } + & a { + text-decoration-line: none; + text-decoration-color: var(--item-color); + &:hover { + text-decoration-line: underline; + } + } + &.active a { text-decoration-line: underline; } } - &.active a { - text-decoration-line: underline; - } + } + } + + .searchBar { + position: sticky; + top: 0; + width: 100%; + padding: 8px 4px; + margin-bottom: 8px; + border: 1px solid var(--gray-2); + border-radius: 4px; + background-color: var(--gray-0); + outline: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 2; + + &:focus-within { + border-color: var(--gray-4); } } } diff --git a/src/components/sidecar/index.tsx b/src/components/sidecar/index.tsx index 3d37f5e1..b84e50d1 100644 --- a/src/components/sidecar/index.tsx +++ b/src/components/sidecar/index.tsx @@ -3,6 +3,7 @@ import classNames from "classnames"; import { H6, P } from "../text"; import s from "./Sidecar.module.css"; import { useStore } from "@/lib/use-store"; +import Fuse from "fuse.js"; interface SidecarItem { id: string; @@ -20,6 +21,10 @@ interface SidecarProps { // it does not make sense to have a single item in the sidecar. const MIN_SIDECAR_ITEMS = 2; +// If there are less items than this, the search bar will not render +// as it is not useful to have a search bar for less than 12 items. +const MIN_SIDECAR_SEARCH_ITEMS = 12; + // H4s and below will only display in the sidecar const MAX_SIDECAR_HEADER_DEPTH = 4; @@ -28,17 +33,33 @@ export default function Sidecar({ items, hidden = false, }: SidecarProps) { + const [searchTerm, setSearchTerm] = useState(""); const activeItemRef = useRef(null); const sidecarRef = useRef(null); const headerIdsInView = useStore((state) => state.headerIdsInView); const shownItems = useMemo(() => { return items.filter((v) => v.depth <= MAX_SIDECAR_HEADER_DEPTH); }, [items]); + + const fuse = useMemo(() => { + return new Fuse(shownItems, { + keys: ["title"], + threshold: 0.3, // This felt pretty good + }); + }, [shownItems]); + + const filteredItems = useMemo(() => { + if (!searchTerm) return shownItems; + return fuse + .search(searchTerm) + .map((result: { item: SidecarItem }) => result.item); + }, [shownItems, searchTerm, fuse]); + const [lastActiveHeaderID, setLastActiveHeaderID] = useState( null, ); const activeHeaderID = useMemo(() => { - const currentActiveID = shownItems.find((v) => + const currentActiveID = filteredItems.find((v) => headerIdsInView.includes(v.id), )?.id; if (currentActiveID && currentActiveID !== lastActiveHeaderID) { @@ -46,7 +67,7 @@ export default function Sidecar({ return currentActiveID; } return lastActiveHeaderID; - }, [shownItems, headerIdsInView, lastActiveHeaderID]); + }, [filteredItems, headerIdsInView, lastActiveHeaderID]); useEffect(() => { if (activeItemRef.current && sidecarRef.current) { @@ -64,35 +85,46 @@ export default function Sidecar({ }, [activeHeaderID]); return ( -
- {items.length > MIN_SIDECAR_ITEMS && !hidden && ( -
    - {items.map(({ id, title, depth }) => { - const active = id === activeHeaderID; - return ( -
  • - {/* Intentionally using an a tag and not next/link: +
    + {items.length > MIN_SIDECAR_SEARCH_ITEMS && !hidden && ( + setSearchTerm(e.target.value)} + className={s.searchBar} + /> + )} +
    + {items.length > MIN_SIDECAR_ITEMS && !hidden && ( +
      + {filteredItems.map(({ id, title, depth }) => { + const active = id === activeHeaderID; + return ( +
    • + {/* Intentionally using an a tag and not next/link: as we want our :target selectors to trigger here. See: https://github.com/vercel/next.js/issues/51346 Also, we're remaining on the same page always here, so no client-side routing handing is needed. */} - -

      {title}

      -
      -
    • - ); - })} -
    - )} + +

    {title}

    +
    +
  • + ); + })} +
+ )} +
); }