mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core & UI: Implement selective re-render for TOC
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'fumadocs-ui': patch
|
||||
'@fumadocs/base-ui': patch
|
||||
'fumadocs-core': patch
|
||||
---
|
||||
|
||||
Implement selective re-render for TOC
|
||||
@@ -135,18 +135,7 @@ export function TOCItems({ ref, className, thumbBox = true, ...props }: TOCItems
|
||||
|
||||
return (
|
||||
<>
|
||||
{svg && (
|
||||
<div
|
||||
className="absolute top-0 inset-s-0"
|
||||
style={{
|
||||
width: svg.width,
|
||||
height: svg.height,
|
||||
}}
|
||||
>
|
||||
<ThumbTrack computed={svg} />
|
||||
{thumbBox && <ThumbBox computed={svg} />}
|
||||
</div>
|
||||
)}
|
||||
{svg && <ThumbTrack computed={svg} thumbBox={thumbBox} />}
|
||||
<div
|
||||
ref={mergeRefs(containerRef, ref)}
|
||||
className={cn('flex flex-col', className)}
|
||||
@@ -172,59 +161,87 @@ interface ThumbBoxInfo {
|
||||
isUp: boolean;
|
||||
}
|
||||
|
||||
function ThumbTrack({ computed }: { computed: ComputedSVG }) {
|
||||
const items = Primitive.useItems();
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
const top = `${computed.positions[startIdx][0]}px`;
|
||||
const bottom = `${computed.positions[endIdx][1]}px`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={`0 0 ${computed.width} ${computed.height}`}
|
||||
className="absolute transition-[clip-path]"
|
||||
style={{
|
||||
width: computed.width,
|
||||
height: computed.height,
|
||||
clipPath: `polygon(0 ${top}, 100% ${top}, 100% ${bottom}, 0 ${bottom})`,
|
||||
}}
|
||||
>
|
||||
{computed.content}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ThumbBox({ computed }: { computed: ComputedSVG }) {
|
||||
const items = Primitive.useItems();
|
||||
function ThumbTrack({ computed, thumbBox }: { computed: ComputedSVG; thumbBox: boolean }) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const previousRef = useRef<ThumbBoxInfo>(null);
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
const tocInfo = Primitive.useTOC();
|
||||
const onUpdate = useCallback(
|
||||
(items: Primitive.TOCItemInfo[]) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
|
||||
let isUp = false;
|
||||
if (previousRef.current) {
|
||||
const prev = previousRef.current;
|
||||
isUp =
|
||||
prev.startIdx > startIdx ||
|
||||
prev.endIdx > endIdx ||
|
||||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
|
||||
}
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
|
||||
previousRef.current = { startIdx, endIdx, isUp };
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
svg.style.setProperty('--track-top', `${computed.positions[startIdx][0]}px`);
|
||||
svg.style.setProperty('--track-bottom', `${computed.positions[endIdx][1]}px`);
|
||||
|
||||
const box = boxRef.current;
|
||||
if (box) {
|
||||
let isUp = false;
|
||||
if (previousRef.current) {
|
||||
const prev = previousRef.current;
|
||||
isUp =
|
||||
prev.startIdx > startIdx ||
|
||||
prev.endIdx > endIdx ||
|
||||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
|
||||
}
|
||||
|
||||
previousRef.current = { startIdx, endIdx, isUp };
|
||||
|
||||
box.style.setProperty(
|
||||
'--offset-distance',
|
||||
isUp
|
||||
? `${computed.itemLineLengths[startIdx][0]}px`
|
||||
: `${computed.itemLineLengths[endIdx][1]}px`,
|
||||
);
|
||||
box.style.setProperty(
|
||||
'--opacity',
|
||||
items[isUp ? startIdx : endIdx].original._step !== undefined ? '0' : '1',
|
||||
);
|
||||
}
|
||||
},
|
||||
[computed],
|
||||
);
|
||||
Primitive.useTOCListener(onUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
onUpdate(tocInfo.get());
|
||||
}, [onUpdate, tocInfo]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute size-1 bg-fd-primary rounded-full transition-[offset-distance]"
|
||||
className="absolute top-0 inset-s-0"
|
||||
style={{
|
||||
offsetPath: `path("${computed.d}")`,
|
||||
offsetDistance: isUp
|
||||
? computed.itemLineLengths[startIdx][0]
|
||||
: computed.itemLineLengths[endIdx][1],
|
||||
scale: items[isUp ? startIdx : endIdx].original._step !== undefined ? '0' : '1',
|
||||
width: computed.width,
|
||||
height: computed.height,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={`0 0 ${computed.width} ${computed.height}`}
|
||||
className="absolute transition-[clip-path]"
|
||||
style={{
|
||||
width: computed.width,
|
||||
height: computed.height,
|
||||
clipPath: `polygon(0 var(--track-top,0), 100% var(--track-top,0), 100% var(--track-bottom,0), 0 var(--track-bottom,0))`,
|
||||
}}
|
||||
>
|
||||
{computed.content}
|
||||
</svg>
|
||||
{thumbBox && (
|
||||
<div
|
||||
ref={boxRef}
|
||||
className="absolute size-1 bg-fd-primary rounded-full [offset-distance:var(--offset-distance,0)] opacity-(--opacity,0) transition-[opacity,offset-distance]"
|
||||
style={{
|
||||
offsetPath: `path("${computed.d}")`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,18 +67,34 @@ export function TOCItems({ ref, className, ...props }: TOCItemsProps) {
|
||||
}
|
||||
|
||||
function TocThumb({ computed }: { computed: ComputedData }) {
|
||||
const items = Primitive.useItems();
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
const top = `${computed.positions[startIdx][0]}px`;
|
||||
const bottom = `${computed.positions[endIdx][1]}px`;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const tocInfo = Primitive.useTOC();
|
||||
const onUpdate = useCallback(
|
||||
(items: Primitive.TOCItemInfo[]) => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
|
||||
element.style.setProperty('--track-top', `${computed.positions[startIdx][0]}px`);
|
||||
element.style.setProperty('--track-bottom', `${computed.positions[endIdx][1]}px`);
|
||||
},
|
||||
[computed],
|
||||
);
|
||||
Primitive.useTOCListener(onUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
onUpdate(tocInfo.get());
|
||||
}, [onUpdate, tocInfo]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute inset-y-0 inset-s-0 bg-fd-primary w-px transition-[clip-path]"
|
||||
style={{
|
||||
clipPath: `polygon(0 ${top}, 100% ${top}, 100% ${bottom}, 0 ${bottom})`,
|
||||
clipPath: `polygon(0 var(--track-top,0), 100% var(--track-top,0), 100% var(--track-bottom,0), 0 var(--track-bottom,0))`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
+150
-81
@@ -6,12 +6,14 @@ import {
|
||||
type RefObject,
|
||||
use,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import scrollIntoView from 'scroll-into-view-if-needed';
|
||||
import { mergeRefs } from '@/utils/merge-refs';
|
||||
import { isEqualShallow } from './utils/is-equal';
|
||||
|
||||
export interface TOCItemType {
|
||||
title: ReactNode;
|
||||
@@ -33,7 +35,7 @@ export interface TOCItemInfo {
|
||||
original: TOCItemType;
|
||||
}
|
||||
|
||||
const ItemsContext = createContext<TOCItemInfo[] | null>(null);
|
||||
const ObserverContext = createContext<Observer | null>(null);
|
||||
const ScrollContext = createContext<RefObject<HTMLElement | null> | null>(null);
|
||||
|
||||
export interface AnchorProviderProps {
|
||||
@@ -63,7 +65,6 @@ export function ScrollProvider({ containerRef, children }: ScrollProviderProps)
|
||||
|
||||
export function AnchorProvider({ toc, single = false, children }: AnchorProviderProps) {
|
||||
const observer = useMemo(() => new Observer(), []);
|
||||
const [items, setItems] = useState<TOCItemInfo[]>(observer.items);
|
||||
|
||||
observer.single = single;
|
||||
useEffect(() => {
|
||||
@@ -72,17 +73,13 @@ export function AnchorProvider({ toc, single = false, children }: AnchorProvider
|
||||
|
||||
useEffect(() => {
|
||||
observer.watch({
|
||||
rootMargin: '0px',
|
||||
threshold: 0.98,
|
||||
threshold: 0.9,
|
||||
});
|
||||
observer.onChange = () => setItems(observer.items);
|
||||
|
||||
return () => {
|
||||
observer.unwatch();
|
||||
};
|
||||
return () => observer.unwatch();
|
||||
}, [observer]);
|
||||
|
||||
return <ItemsContext value={items}>{children}</ItemsContext>;
|
||||
return <ObserverContext value={observer}>{children}</ObserverContext>;
|
||||
}
|
||||
|
||||
export interface TOCItemProps extends ComponentProps<'a'> {
|
||||
@@ -91,48 +88,106 @@ export interface TOCItemProps extends ComponentProps<'a'> {
|
||||
|
||||
export function TOCItem({ ref, onActiveChange = () => null, ...props }: TOCItemProps) {
|
||||
const id = props.href ? getItemId(props.href) : null;
|
||||
const items = useItems();
|
||||
const itemData = id ? items.find((item) => item.id === id) : null;
|
||||
|
||||
const containerRef = use(ScrollContext);
|
||||
const isInitialMountRef = useRef(true);
|
||||
const anchorRef = useRef<HTMLAnchorElement>(null);
|
||||
const [active, setActive] = useState(() => itemData != null && itemData.active);
|
||||
const observer = useObserver();
|
||||
const [active, setActive] = useState(() =>
|
||||
observer.items.some((item) => item.id === id && item.active),
|
||||
);
|
||||
|
||||
if (itemData && itemData.active !== active) {
|
||||
setActive(itemData.active);
|
||||
onActiveChange(itemData.active);
|
||||
}
|
||||
|
||||
const shouldScroll =
|
||||
itemData?.active && items.every((item) => !item.active || item.t <= itemData.t);
|
||||
useEffect(() => {
|
||||
function autoScroll(items: TOCItemInfo[], instant = false) {
|
||||
const anchor = anchorRef.current;
|
||||
const container = containerRef?.current;
|
||||
if (!id || !anchor || !container) return;
|
||||
|
||||
if (shouldScroll && container && anchor) {
|
||||
let lastActive: TOCItemInfo | undefined;
|
||||
for (const item of items) {
|
||||
if (!item.active) continue;
|
||||
if (!lastActive || lastActive.t < item.t) {
|
||||
lastActive = item;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastActive?.id === id) {
|
||||
scrollIntoView(anchor, {
|
||||
behavior: isInitialMountRef.current ? 'instant' : 'smooth',
|
||||
behavior: instant ? 'instant' : 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
scrollMode: 'always',
|
||||
boundary: container,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isInitialMountRef.current = false;
|
||||
}, [shouldScroll, containerRef]);
|
||||
useTOCListener((items) => {
|
||||
const itemData = id ? items.find((item) => item.id === id) : null;
|
||||
|
||||
if (itemData && itemData.active !== active) {
|
||||
setActive(itemData.active);
|
||||
onActiveChange(itemData.active);
|
||||
autoScroll(items);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
autoScroll(observer.items, true);
|
||||
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- initial only
|
||||
}, [observer]);
|
||||
|
||||
return <a ref={mergeRefs(anchorRef, ref)} data-active={active} {...props} />;
|
||||
}
|
||||
|
||||
function useObserver() {
|
||||
const observer = use(ObserverContext);
|
||||
if (!observer) throw new Error(`Component must be used under the <AnchorProvider /> component.`);
|
||||
return observer;
|
||||
}
|
||||
|
||||
/** @returns static info object, useful for custom rendering logic */
|
||||
export function useTOC() {
|
||||
const observer = useObserver();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
get() {
|
||||
return observer.items;
|
||||
},
|
||||
listen: observer.listen.bind(observer),
|
||||
unlisten: observer.unlisten.bind(observer),
|
||||
}),
|
||||
[observer],
|
||||
);
|
||||
}
|
||||
|
||||
export function useTOCListener(listener: ChangeListener) {
|
||||
const observer = useObserver();
|
||||
const callback = useEffectEvent(listener);
|
||||
|
||||
useEffect(() => {
|
||||
observer.listen(callback);
|
||||
return () => observer.unlisten(callback);
|
||||
}, [observer]);
|
||||
}
|
||||
|
||||
export function useTOCSelector<T>(
|
||||
select: (items: TOCItemInfo[]) => T,
|
||||
isEqual: (a: T, b: T) => boolean = isEqualShallow,
|
||||
) {
|
||||
const observer = useObserver();
|
||||
const [value, setValue] = useState<T>(() => select(observer.items));
|
||||
useTOCListener((items) => {
|
||||
const next = select(items);
|
||||
if (!isEqual(value, next)) setValue(next);
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The estimated active heading ID
|
||||
*/
|
||||
export function useActiveAnchor(): string | undefined {
|
||||
const items = useItems();
|
||||
|
||||
return useMemo(() => {
|
||||
return useTOCSelector((items) => {
|
||||
let out: TOCItemInfo | undefined;
|
||||
for (const item of items) {
|
||||
if (!item.active) continue;
|
||||
@@ -143,27 +198,24 @@ export function useActiveAnchor(): string | undefined {
|
||||
}
|
||||
|
||||
return out?.id;
|
||||
}, [items]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of visible anchors
|
||||
*/
|
||||
export function useActiveAnchors(): string[] {
|
||||
const items = useItems();
|
||||
return useMemo(() => {
|
||||
return useTOCSelector((items) => {
|
||||
const out: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item.active) out.push(item.id);
|
||||
}
|
||||
return out;
|
||||
}, [items]);
|
||||
});
|
||||
}
|
||||
|
||||
export function useItems() {
|
||||
const ctx = use(ItemsContext);
|
||||
if (!ctx) throw new Error(`Component must be used under the <AnchorProvider /> component.`);
|
||||
return ctx;
|
||||
export function useItems(): TOCItemInfo[] {
|
||||
return useTOCSelector((items) => items);
|
||||
}
|
||||
|
||||
function getItemId(url: string) {
|
||||
@@ -171,17 +223,67 @@ function getItemId(url: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
type ChangeListener = (items: TOCItemInfo[]) => void;
|
||||
|
||||
class Observer {
|
||||
items: TOCItemInfo[] = [];
|
||||
single = false;
|
||||
private observer: IntersectionObserver | null = null;
|
||||
onChange?: () => void;
|
||||
private listeners = new Set<ChangeListener>();
|
||||
|
||||
listen(listener: ChangeListener) {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
unlisten(listener: ChangeListener) {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
setItems(newItems: TOCItemType[]) {
|
||||
const observer = this.observer;
|
||||
if (observer) {
|
||||
for (const item of this.items) {
|
||||
const element = document.getElementById(item.id);
|
||||
if (!element) continue;
|
||||
observer.unobserve(element);
|
||||
}
|
||||
}
|
||||
|
||||
const next: TOCItemInfo[] = [];
|
||||
for (const item of newItems) {
|
||||
const id = getItemId(item.url);
|
||||
if (!id) continue;
|
||||
|
||||
next.push({
|
||||
id,
|
||||
active: false,
|
||||
fallback: false,
|
||||
t: 0,
|
||||
original: item,
|
||||
});
|
||||
}
|
||||
|
||||
this.update(next);
|
||||
this.observeItems();
|
||||
}
|
||||
|
||||
watch(options?: IntersectionObserverInit) {
|
||||
if (this.observer) return;
|
||||
|
||||
this.observer = new IntersectionObserver(this.callback.bind(this), options);
|
||||
this.observeItems();
|
||||
}
|
||||
|
||||
unwatch() {
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
|
||||
private callback(entries: IntersectionObserverEntry[]) {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
let hasActive = false;
|
||||
this.items = this.items.map((item) => {
|
||||
const updated = this.items.map((item) => {
|
||||
const entry = entries.find((entry) => entry.target.id === item.id);
|
||||
let active = entry ? entry.isIntersecting : item.active && !item.fallback;
|
||||
if (this.single && hasActive) active = false;
|
||||
@@ -204,8 +306,8 @@ class Observer {
|
||||
let min = Number.MAX_VALUE;
|
||||
let fallbackIdx = -1;
|
||||
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const element = document.getElementById(this.items[i].id);
|
||||
for (let i = 0; i < updated.length; i++) {
|
||||
const element = document.getElementById(updated[i].id);
|
||||
if (!element) continue;
|
||||
|
||||
const d = Math.abs(viewTop - element.getBoundingClientRect().top);
|
||||
@@ -216,8 +318,8 @@ class Observer {
|
||||
}
|
||||
|
||||
if (fallbackIdx !== -1) {
|
||||
this.items[fallbackIdx] = {
|
||||
...this.items[fallbackIdx],
|
||||
updated[fallbackIdx] = {
|
||||
...updated[fallbackIdx],
|
||||
active: true,
|
||||
fallback: true,
|
||||
t: Date.now(),
|
||||
@@ -225,43 +327,10 @@ class Observer {
|
||||
}
|
||||
}
|
||||
|
||||
this.onChange?.();
|
||||
this.update(updated);
|
||||
}
|
||||
|
||||
setItems(newItems: TOCItemType[]) {
|
||||
const observer = this.observer;
|
||||
if (observer) {
|
||||
for (const item of this.items) {
|
||||
const element = document.getElementById(item.id);
|
||||
if (!element) continue;
|
||||
observer.unobserve(element);
|
||||
}
|
||||
}
|
||||
|
||||
this.items = [];
|
||||
for (const item of newItems) {
|
||||
const id = getItemId(item.url);
|
||||
if (!id) continue;
|
||||
|
||||
this.items.push({
|
||||
id,
|
||||
active: false,
|
||||
fallback: false,
|
||||
t: 0,
|
||||
original: item,
|
||||
});
|
||||
}
|
||||
this.watchItems();
|
||||
}
|
||||
|
||||
watch(options?: IntersectionObserverInit) {
|
||||
if (this.observer) return;
|
||||
|
||||
this.observer = new IntersectionObserver(this.callback.bind(this), options);
|
||||
this.watchItems();
|
||||
}
|
||||
|
||||
private watchItems() {
|
||||
private observeItems() {
|
||||
if (!this.observer) return;
|
||||
for (const item of this.items) {
|
||||
const element = document.getElementById(item.id);
|
||||
@@ -270,8 +339,8 @@ class Observer {
|
||||
}
|
||||
}
|
||||
|
||||
unwatch() {
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
private update(next: TOCItemInfo[]) {
|
||||
this.items = next;
|
||||
for (const listener of this.listeners) listener(next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function isEqualShallow(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return b.length === a.length && a.every((v, i) => isEqualShallow(v, b[i]));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
function isDifferent(a: unknown, b: unknown): boolean {
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return b.length !== a.length || a.some((v, i) => isDifferent(v, b[i]));
|
||||
}
|
||||
|
||||
return a !== b;
|
||||
}
|
||||
import { isEqualShallow } from './is-equal';
|
||||
|
||||
/**
|
||||
* @param value - state to watch
|
||||
@@ -16,7 +9,7 @@ function isDifferent(a: unknown, b: unknown): boolean {
|
||||
export function useOnChange<T>(
|
||||
value: T,
|
||||
onChange: (current: T, previous: T) => void,
|
||||
isUpdated: (prev: T, current: T) => boolean = isDifferent,
|
||||
isUpdated: (prev: T, current: T) => boolean = (a, b) => !isEqualShallow(a, b),
|
||||
): void {
|
||||
const [prev, setPrev] = useState<T>(value);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ async function compileConfig(core: Core) {
|
||||
entryPoints: [{ in: configPath, out: 'source.config' }],
|
||||
bundle: true,
|
||||
outdir: outDir,
|
||||
target: 'node20',
|
||||
target: 'node22',
|
||||
write: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { loadConfig } from '@/config/load-from-file';
|
||||
import type { Core } from '@/core';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
@@ -43,13 +44,9 @@ export function createStandaloneConfigLoader({
|
||||
if (!prev || hash !== prev.hash) {
|
||||
prev = {
|
||||
hash,
|
||||
init: (async () => {
|
||||
const { loadConfig } = await import('../config/load-from-file');
|
||||
|
||||
await core.init({
|
||||
config: loadConfig(core, buildConfig),
|
||||
});
|
||||
})(),
|
||||
init: core.init({
|
||||
config: loadConfig(core, buildConfig),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -135,18 +135,7 @@ export function TOCItems({ ref, className, thumbBox = true, ...props }: TOCItems
|
||||
|
||||
return (
|
||||
<>
|
||||
{svg && (
|
||||
<div
|
||||
className="absolute top-0 inset-s-0"
|
||||
style={{
|
||||
width: svg.width,
|
||||
height: svg.height,
|
||||
}}
|
||||
>
|
||||
<ThumbTrack computed={svg} />
|
||||
{thumbBox && <ThumbBox computed={svg} />}
|
||||
</div>
|
||||
)}
|
||||
{svg && <ThumbTrack computed={svg} thumbBox={thumbBox} />}
|
||||
<div
|
||||
ref={mergeRefs(containerRef, ref)}
|
||||
className={cn('flex flex-col', className)}
|
||||
@@ -172,59 +161,87 @@ interface ThumbBoxInfo {
|
||||
isUp: boolean;
|
||||
}
|
||||
|
||||
function ThumbTrack({ computed }: { computed: ComputedSVG }) {
|
||||
const items = Primitive.useItems();
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
const top = `${computed.positions[startIdx][0]}px`;
|
||||
const bottom = `${computed.positions[endIdx][1]}px`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={`0 0 ${computed.width} ${computed.height}`}
|
||||
className="absolute transition-[clip-path]"
|
||||
style={{
|
||||
width: computed.width,
|
||||
height: computed.height,
|
||||
clipPath: `polygon(0 ${top}, 100% ${top}, 100% ${bottom}, 0 ${bottom})`,
|
||||
}}
|
||||
>
|
||||
{computed.content}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ThumbBox({ computed }: { computed: ComputedSVG }) {
|
||||
const items = Primitive.useItems();
|
||||
function ThumbTrack({ computed, thumbBox }: { computed: ComputedSVG; thumbBox: boolean }) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const previousRef = useRef<ThumbBoxInfo>(null);
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
const tocInfo = Primitive.useTOC();
|
||||
const onUpdate = useCallback(
|
||||
(items: Primitive.TOCItemInfo[]) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
|
||||
let isUp = false;
|
||||
if (previousRef.current) {
|
||||
const prev = previousRef.current;
|
||||
isUp =
|
||||
prev.startIdx > startIdx ||
|
||||
prev.endIdx > endIdx ||
|
||||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
|
||||
}
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
|
||||
previousRef.current = { startIdx, endIdx, isUp };
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
svg.style.setProperty('--track-top', `${computed.positions[startIdx][0]}px`);
|
||||
svg.style.setProperty('--track-bottom', `${computed.positions[endIdx][1]}px`);
|
||||
|
||||
const box = boxRef.current;
|
||||
if (box) {
|
||||
let isUp = false;
|
||||
if (previousRef.current) {
|
||||
const prev = previousRef.current;
|
||||
isUp =
|
||||
prev.startIdx > startIdx ||
|
||||
prev.endIdx > endIdx ||
|
||||
(prev.startIdx === startIdx && prev.endIdx === endIdx && prev.isUp);
|
||||
}
|
||||
|
||||
previousRef.current = { startIdx, endIdx, isUp };
|
||||
|
||||
box.style.setProperty(
|
||||
'--offset-distance',
|
||||
isUp
|
||||
? `${computed.itemLineLengths[startIdx][0]}px`
|
||||
: `${computed.itemLineLengths[endIdx][1]}px`,
|
||||
);
|
||||
box.style.setProperty(
|
||||
'--opacity',
|
||||
items[isUp ? startIdx : endIdx].original._step !== undefined ? '0' : '1',
|
||||
);
|
||||
}
|
||||
},
|
||||
[computed],
|
||||
);
|
||||
Primitive.useTOCListener(onUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
onUpdate(tocInfo.get());
|
||||
}, [onUpdate, tocInfo]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute size-1 bg-fd-primary rounded-full transition-[offset-distance]"
|
||||
className="absolute top-0 inset-s-0"
|
||||
style={{
|
||||
offsetPath: `path("${computed.d}")`,
|
||||
offsetDistance: isUp
|
||||
? computed.itemLineLengths[startIdx][0]
|
||||
: computed.itemLineLengths[endIdx][1],
|
||||
scale: items[isUp ? startIdx : endIdx].original._step !== undefined ? '0' : '1',
|
||||
width: computed.width,
|
||||
height: computed.height,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={`0 0 ${computed.width} ${computed.height}`}
|
||||
className="absolute transition-[clip-path]"
|
||||
style={{
|
||||
width: computed.width,
|
||||
height: computed.height,
|
||||
clipPath: `polygon(0 var(--track-top,0), 100% var(--track-top,0), 100% var(--track-bottom,0), 0 var(--track-bottom,0))`,
|
||||
}}
|
||||
>
|
||||
{computed.content}
|
||||
</svg>
|
||||
{thumbBox && (
|
||||
<div
|
||||
ref={boxRef}
|
||||
className="absolute size-1 bg-fd-primary rounded-full [offset-distance:var(--offset-distance,0)] opacity-(--opacity,0) transition-[opacity,offset-distance]"
|
||||
style={{
|
||||
offsetPath: `path("${computed.d}")`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,18 +67,34 @@ export function TOCItems({ ref, className, ...props }: TOCItemsProps) {
|
||||
}
|
||||
|
||||
function TocThumb({ computed }: { computed: ComputedData }) {
|
||||
const items = Primitive.useItems();
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
const top = `${computed.positions[startIdx][0]}px`;
|
||||
const bottom = `${computed.positions[endIdx][1]}px`;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const tocInfo = Primitive.useTOC();
|
||||
const onUpdate = useCallback(
|
||||
(items: Primitive.TOCItemInfo[]) => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const startIdx = items.findIndex((item) => item.active);
|
||||
if (startIdx === -1) return;
|
||||
const endIdx = items.findLastIndex((item) => item.active);
|
||||
|
||||
element.style.setProperty('--track-top', `${computed.positions[startIdx][0]}px`);
|
||||
element.style.setProperty('--track-bottom', `${computed.positions[endIdx][1]}px`);
|
||||
},
|
||||
[computed],
|
||||
);
|
||||
Primitive.useTOCListener(onUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
onUpdate(tocInfo.get());
|
||||
}, [onUpdate, tocInfo]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute inset-y-0 inset-s-0 bg-fd-primary w-px transition-[clip-path]"
|
||||
style={{
|
||||
clipPath: `polygon(0 ${top}, 100% ${top}, 100% ${bottom}, 0 ${bottom})`,
|
||||
clipPath: `polygon(0 var(--track-top,0), 100% var(--track-top,0), 100% var(--track-bottom,0), 0 var(--track-bottom,0))`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user