Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
144 changes: 87 additions & 57 deletions src/components/sidecar/Sidecar.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
86 changes: 59 additions & 27 deletions src/components/sidecar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -28,25 +33,41 @@ export default function Sidecar({
items,
hidden = false,
}: SidecarProps) {
const [searchTerm, setSearchTerm] = useState("");
const activeItemRef = useRef<HTMLLIElement>(null);
const sidecarRef = useRef<HTMLDivElement>(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<string | null>(
null,
);
const activeHeaderID = useMemo(() => {
const currentActiveID = shownItems.find((v) =>
const currentActiveID = filteredItems.find((v) =>
headerIdsInView.includes(v.id),
)?.id;
if (currentActiveID && currentActiveID !== lastActiveHeaderID) {
setLastActiveHeaderID(currentActiveID);
return currentActiveID;
}
return lastActiveHeaderID;
}, [shownItems, headerIdsInView, lastActiveHeaderID]);
}, [filteredItems, headerIdsInView, lastActiveHeaderID]);

useEffect(() => {
if (activeItemRef.current && sidecarRef.current) {
Expand All @@ -64,35 +85,46 @@ export default function Sidecar({
}, [activeHeaderID]);

return (
<div ref={sidecarRef} className={classNames(s.sidecar, className)}>
{items.length > MIN_SIDECAR_ITEMS && !hidden && (
<ul>
{items.map(({ id, title, depth }) => {
const active = id === activeHeaderID;
return (
<li
key={`${id}${active}`}
ref={active ? activeItemRef : null}
className={classNames({ [s.active]: active })}
style={
{
"--depth": depth,
} as React.CSSProperties
}
>
{/* Intentionally using an a tag and not next/link:
<div className={classNames(s.sidecarWrapper, className)}>
{items.length > MIN_SIDECAR_SEARCH_ITEMS && !hidden && (
<input
type="text"
placeholder="🔎 Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={s.searchBar}
/>
)}
<div ref={sidecarRef} className={classNames(s.sidecar, className)}>
{items.length > MIN_SIDECAR_ITEMS && !hidden && (
<ul>
{filteredItems.map(({ id, title, depth }) => {
const active = id === activeHeaderID;
return (
<li
key={`${id}${active}`}
ref={active ? activeItemRef : null}
className={classNames({ [s.active]: active })}
style={
{
"--depth": depth,
} as React.CSSProperties
}
>
{/* 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. */}
<a href={`#${id}`}>
<P weight={active ? "medium" : "regular"}>{title}</P>
</a>
</li>
);
})}
</ul>
)}
<a href={`#${id}`}>
<P weight={active ? "medium" : "regular"}>{title}</P>
</a>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}