Skip to content

Commit

Permalink
feat: impl useAsideAnchor to optimize UX
Browse files Browse the repository at this point in the history
  • Loading branch information
sanyuan0704 committed Sep 17, 2022
1 parent 2bc4b41 commit 5c74835
Show file tree
Hide file tree
Showing 16 changed files with 179 additions and 85 deletions.
2 changes: 1 addition & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
**/node_modules/**
package.json
*.d.ts
.eslintrc.js
.eslintrc.cjs
tsconfig.json
6 changes: 4 additions & 2 deletions .eslintrc.js → .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ module.exports = {
ecmaVersion: 'latest',
sourceType: 'module'
},
plugins: ['react', '@typescript-eslint', 'prettier'],
plugins: ['react', '@typescript-eslint', 'prettier', 'react-hooks'],
rules: {
'prettier/prettier': 'error',
quotes: ['error', 'single'],
semi: ['error', 'always'],
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-non-null-assertion': 'off'
'@typescript-eslint/no-non-null-assertion': 'off',
'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks
'react-hooks/exhaustive-deps': 'warn' // Checks effect dependencies
},
settings: {
react: {
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@
"@typescript-eslint/parser": "^5.36.1",
"es-module-lexer": "^1.0.3",
"eslint": "^8.23.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.1",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.7.1",
"resolve": "^1.22.1",
"rollup": "^2.78.1",
"tsup": "^6.2.3",
Expand Down
60 changes: 60 additions & 0 deletions pnpm-lock.yaml

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

2 changes: 1 addition & 1 deletion src/client/runtime/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export function App({

useLayoutEffect(() => {
async function refetchData() {
const pageData = await waitForApp(pathname);
try {
const pageData = await waitForApp(pathname);
setPageData(pageData);
} catch (e) {
console.log(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
}

.outline-link:hover,
.outline-link.active {
.outline-link:global(.aside-active) {
color: var(--island-c-text-1);
transition: color 0.25s;
}
Expand Down
83 changes: 11 additions & 72 deletions src/client/theme-default/components/Aside/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import { useState, useEffect, useRef } from 'react';
import styles from './index.module.scss';
import { throttle } from 'lodash-es';
import { ComponentPropsWithIsland, Header } from 'shared/types/index';

function isBottom() {
return (
document.documentElement.scrollTop + window.innerHeight >=
document.documentElement.scrollHeight
);
}
import { useAsideAnchor } from '../../logic';

export function Aside(
props: ComponentPropsWithIsland<{ headers: Header[]; pagePath: string }>
) {
const [headers, setHeaders] = useState(props.headers || []);

// For outline text highlight
const [activeIndex, setActiveIndex] = useState(0);
const markerRef = useRef<HTMLDivElement>(null);
const SCROLL_INTO_HEIGHT = 150;
const NAV_HEIGHT = 72;
const asideRef = useRef<HTMLDivElement>(null);
const prevActiveLinkRef = useRef<HTMLAnchorElement>(null);

useEffect(() => {
setHeaders(props.headers);
}, [props.headers]);

useEffect(() => {
// handle hmr
Expand All @@ -32,73 +27,17 @@ export function Aside(
});
});
}
}, []);

function setActiveLink() {
const links = document.querySelectorAll<HTMLAnchorElement>(
'.island-doc .header-anchor'
);
if (isBottom()) {
setActiveIndex(links.length - 1);
markerRef.current!.style.top = `${33 + (headers.length - 1) * 28}px`;
} else {
// Compute current index
for (let i = 0; i < links.length; i++) {
const topDistance = links[i].getBoundingClientRect().top;
if (topDistance > NAV_HEIGHT && topDistance < SCROLL_INTO_HEIGHT) {
const id = links[i].getAttribute('href');
const index = headers.findIndex(
(item: any) => item.id === id?.slice(1)
);
if (index > -1 && index !== activeIndex) {
setActiveIndex(index);
markerRef.current!.style.top = `${33 + index * 28}px`;
} else {
setActiveIndex(0);
markerRef.current!.style.top = '33px';
}
break;
}
}
}
}

useEffect(() => {
if (!headers.length && markerRef.current) {
markerRef.current.style.display = 'none';
}
const onScroll = throttle(
function listen() {
if (!headers.length) {
return;
}
setActiveLink();
},
100,
{ trailing: true }
);
window.addEventListener('scroll', onScroll);
}, [props.pagePath]);

return () => {
window.removeEventListener('scroll', onScroll);
};
}, []);
useAsideAnchor(prevActiveLinkRef, headers, asideRef, markerRef);

const renderHeader = (header: any, index: number) => {
const isNested = header.depth > 2;
return (
<li key={header.text}>
<a
href={`#${header.id}`}
onClick={(e) => {
e.preventDefault();
setTimeout(() => {
setActiveLink();
}, 300);
}}
className={`${styles.outlineLink} ${
index == activeIndex ? styles.active : ''
} ${isNested ? styles.nested : ''}`}
className={`${styles.outlineLink} ${isNested ? styles.nested : ''}`}
>
{header.text}
</a>
Expand All @@ -109,7 +48,7 @@ export function Aside(
return (
<div className={styles.docAside}>
<div className={styles.docsAsideOutline}>
<div className={styles.content}>
<div className={styles.content} ref={asideRef}>
<div className={styles.outlineMarker} ref={markerRef}></div>
<div className={styles.outlineTitle}>ON THIS PAGE</div>
<nav>
Expand Down
2 changes: 0 additions & 2 deletions src/client/theme-default/layout/DocLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@ export function DocLayout() {
const data = usePageData();
const headers = data?.toc || [];
const sidebar = data?.siteData?.themeConfig?.sidebar || [];

const hasSidebar =
(Array.isArray(sidebar) && sidebar.length > 0) ||
Object.keys(sidebar).length > 0;

const hasAside = headers.length > 0;

return (
<div className={styles.doc}>
<div className={styles.sideBar}>{hasSidebar ? <SideBar /> : null}</div>
Expand Down
2 changes: 2 additions & 0 deletions src/client/theme-default/logic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export function normalizeHref(url?: string) {
const suffix = import.meta.env.ENABLE_SPA ? '' : '.html';
return addLeadingSlash(`${url}${suffix}`);
}

export { useAsideAnchor } from './useAsideAnchor';
89 changes: 89 additions & 0 deletions src/client/theme-default/logic/useAsideAnchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { throttle } from 'lodash-es';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Header } from 'shared/types/index';

function isBottom() {
return (
document.documentElement.scrollTop + window.innerHeight >=
document.documentElement.scrollHeight
);
}

const NAV_HEIGHT = 72;

export function useAsideAnchor(
prevActiveLinkRef: React.MutableRefObject<HTMLAnchorElement | null>,
headers: Header[],
asideRef: React.MutableRefObject<HTMLDivElement | null>,
markerRef: React.MutableRefObject<HTMLDivElement | null>
) {
const { pathname } = useLocation();
useEffect(() => {
if (!headers.length && markerRef.current) {
markerRef.current.style.opacity = '0';
}
// Util function to set dom ref after determining the active link
const activate = (links: NodeListOf<HTMLAnchorElement>, index: number) => {
if (prevActiveLinkRef.current) {
prevActiveLinkRef.current.classList.remove('aside-active');
}
if (links[index]) {
links[index].classList.add('aside-active');
const id = links[index].getAttribute('href');
const hash = id?.slice(1);
const tocIndex = headers.findIndex((item: Header) => item.id === hash);
const currentLink = asideRef.current?.querySelector(
`a[href="#${hash}"]`
);
if (currentLink) {
prevActiveLinkRef.current = currentLink as HTMLAnchorElement;
// Activate the a link element in aside
prevActiveLinkRef.current.classList.add('aside-active');
// Activate the marker element
markerRef.current!.style.top = `${33 + tocIndex * 28}px`;
markerRef.current!.style.opacity = '1';
}
}
};
const setActiveLink = () => {
const links = document.querySelectorAll<HTMLAnchorElement>(
'.island-doc .header-anchor'
);
if (isBottom()) {
activate(links, links.length - 1);
} else {
// Compute current index
for (let i = 0; i < links.length; i++) {
const currentAnchor = links[i];
const nextAnchor = links[i + 1];
const scrollTop = window.scrollY;
const currentAnchorTop =
currentAnchor.parentElement!.offsetTop - NAV_HEIGHT;
const nextAnchorTop =
nextAnchor.parentElement!.offsetTop - NAV_HEIGHT;

if (scrollTop > currentAnchorTop && scrollTop < nextAnchorTop) {
activate(links, i);
break;
}
}
}
};
const throttledSetLink = throttle(setActiveLink, 100);
requestAnimationFrame(setActiveLink);

window.addEventListener('scroll', throttledSetLink);

return () => {
window.removeEventListener('scroll', throttledSetLink);
};
}, [asideRef, headers, headers.length, markerRef, prevActiveLinkRef]);

useEffect(() => {
if (markerRef.current) {
markerRef.current.style.opacity = '0';
window.scrollTo(0, 0);
}
}, [markerRef, pathname]);
}
1 change: 0 additions & 1 deletion src/client/theme-default/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ html {
line-height: 1.4;
font-size: 16px;
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}

html.dark {
Expand Down
Loading

0 comments on commit 5c74835

Please sign in to comment.