Skip to content

Commit

Permalink
feat: sticky header & markdown toc (#141)
Browse files Browse the repository at this point in the history
* feat: keep the position of breadcrumb sticky to top while scrolling

* feat: add a button with function "back to top"

* feat: add toc for markdown previewer

* fix: reassignment error

* fix: toc visible issue while changed page

* feat: add local setting for sticky position

* refactor: move toc component into markdown component
  • Loading branch information
liuycy committed Feb 5, 2024
1 parent 972fcf5 commit 045ce1d
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 16 deletions.
147 changes: 144 additions & 3 deletions src/components/Markdown.tsx
Expand Up @@ -6,13 +6,147 @@ import rehypeRaw from "rehype-raw"
import reMarkMath from "remark-math"
import rehypeKatex from "rehype-katex"
import "./markdown.css"
import { Show, createEffect, createMemo, createSignal, on } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, on } from "solid-js"
import { clsx } from "clsx"
import { Box } from "@hope-ui/solid"
import { Anchor, Box, List, ListItem } from "@hope-ui/solid"
import { useParseText, useRouter } from "~/hooks"
import { EncodingSelect } from "."
import once from "just-once"
import { pathDir, pathJoin, api } from "~/utils"
import { createStorageSignal } from "@solid-primitives/storage"
import { isMobile } from "~/utils/compatibility.js"
import { useScrollListener } from "~/pages/home/toolbar/BackTop.jsx"
import { Motion } from "@motionone/solid"
import { getMainColor } from "~/store"

type TocItem = { indent: number; text: string; tagName: string; key: string }

const [isTocVisible, setVisible] = createSignal(false)
const [markdownRef, setMarkdownRef] = createSignal<HTMLDivElement>()
const [isTocDisabled, setTocDisabled] = createStorageSignal(
"isMarkdownTocDisabled",
false,
{
serializer: (v: boolean) => JSON.stringify(v),
deserializer: (v) => JSON.parse(v),
},
)

export { isTocVisible, setTocDisabled }

function MarkdownToc(props: { disabled?: boolean }) {
if (props.disabled) return null
if (isMobile) return null

const [tocList, setTocList] = createSignal<TocItem[]>([])

useScrollListener(
() => setVisible(window.scrollY > 100 && tocList().length > 1),
{ immediate: true },
)

createEffect(() => {
const $markdown = markdownRef()?.querySelector(".markdown-body")
if (!$markdown) return

/**
* iterate elements of markdown body to find h1~h6
* and put them into a list by order
*/
const iterator = document.createNodeIterator(
$markdown,
NodeFilter.SHOW_ELEMENT,
{
acceptNode(node) {
if (/h1|h2|h3/i.test(node.nodeName)) {
return NodeFilter.FILTER_ACCEPT
}
return NodeFilter.FILTER_REJECT
},
},
)

const items: TocItem[] = []
let $next = iterator.nextNode()
let minLevel = 6

while ($next) {
const level = Number($next.nodeName.match(/h(\d)/i)![1])
if (level < minLevel) minLevel = level

items.push({
indent: level, // initial indent for following compute
text: $next.textContent!,
tagName: $next.nodeName.toLowerCase(),
key: ($next as Element).getAttribute("key")!,
})

$next = iterator.nextNode()
}

setTocList(
items.map((item) => ({
...item,
// reset the indent of item to remove whitespace
indent: item.indent - minLevel,
})),
)
})

const handleAnchor = (item: TocItem) => {
const $target = document.querySelector(`${item.tagName}[key=${item.key}]`)
if (!$target) return

// the top of target should scroll to the bottom of nav
const $nav = document.querySelector(".nav")
let navBottom = $nav?.getBoundingClientRect().bottom ?? 0
if (navBottom < 0) navBottom = 0

const offsetY = $target.getBoundingClientRect().y
window.scrollBy({ behavior: "smooth", top: offsetY - navBottom })
}

return (
<Show when={!isTocDisabled() && isTocVisible()}>
<Box
as={Motion.div}
initial={{ x: 999 }}
animate={{ x: 0 }}
zIndex="$overlay"
pos="fixed"
right="$6"
top="$6"
>
<Box
mt="$5"
p="$2"
shadow="$outline"
rounded="$lg"
bgColor="white"
transition="all .3s ease-out"
transform="translateX(calc(100% - 20px))"
_dark={{ bgColor: "$neutral3" }}
_hover={{ transform: "none" }}
>
<List maxH="60vh" overflowY="auto">
<For each={tocList()}>
{(item) => (
<ListItem pl={15 * item.indent} m={4}>
<Anchor
color={getMainColor()}
onClick={() => handleAnchor(item)}
>
{item.text}
</Anchor>
</ListItem>
)}
</For>
</List>
</Box>
</Box>
</Show>
)
}

const insertKatexCSS = once(() => {
const link = document.createElement("link")
Expand All @@ -27,6 +161,7 @@ export function Markdown(props: {
class?: string
ext?: string
readme?: boolean
toc?: boolean
}) {
const [encoding, setEncoding] = createSignal<string>("utf-8")
const [show, setShow] = createSignal(true)
Expand Down Expand Up @@ -77,7 +212,12 @@ export function Markdown(props: {
}),
)
return (
<Box class="markdown" pos="relative" w="$full">
<Box
ref={(r: HTMLDivElement) => setMarkdownRef(r)}
class="markdown"
pos="relative"
w="$full"
>
<Show when={show()}>
<SolidMarkdown
class={clsx("markdown-body", props.class)}
Expand All @@ -89,6 +229,7 @@ export function Markdown(props: {
<Show when={!isString}>
<EncodingSelect encoding={encoding()} setEncoding={setEncoding} />
</Show>
<MarkdownToc disabled={!props.toc} />
</Box>
)
}
7 changes: 7 additions & 0 deletions src/lang/en/home.json
Expand Up @@ -30,6 +30,7 @@
"refresh": "Refresh",
"toggle_theme": "Toggle Theme",
"switch_lang": "Switch Language",
"toggle_markdown_toc": "Toggle Outline",
"toggle_checkbox": "Toggle Checkbox",
"rename": "Rename",
"input_new_name": "Input new name",
Expand Down Expand Up @@ -110,6 +111,12 @@
"list": "List View",
"grid": "Grid View",
"image": "Image View"
},
"position_of_header_navbar": "Position of header & nav bar",
"position_of_header_navbar_options": {
"static": "Normal",
"sticky": "Stick to top of page",
"only_navbar_sticky": "Only nav bar sticky"
}
},
"package_download": {
Expand Down
36 changes: 34 additions & 2 deletions src/pages/home/Nav.tsx
Expand Up @@ -2,21 +2,53 @@ import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbProps,
BreadcrumbSeparator,
} from "@hope-ui/solid"
import { Link } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { usePath, useRouter, useT } from "~/hooks"
import { getSetting } from "~/store"
import { getSetting, local } from "~/store"
import { encodePath, hoverColor, joinBase } from "~/utils"

export const Nav = () => {
const { pathname } = useRouter()
const paths = createMemo(() => ["", ...pathname().split("/").filter(Boolean)])
const t = useT()
const { setPathAs } = usePath()

const stickyProps = createMemo<BreadcrumbProps>(() => {
const mask: BreadcrumbProps = {
_after: {
content: "",
bgColor: "$background",
position: "absolute",
height: "100%",
width: "99vw",
zIndex: -1,
transform: "translateX(-50%)",
left: "50%",
top: 0,
},
}

switch (local["position_of_header_navbar"]) {
case "only_navbar_sticky":
return { ...mask, position: "sticky", zIndex: "$sticky", top: 0 }
case "sticky":
return { ...mask, position: "sticky", zIndex: "$sticky", top: 60 }
default:
return {
_after: undefined,
position: undefined,
zIndex: undefined,
top: undefined,
}
}
})

return (
<Breadcrumb class="nav" w="$full">
<Breadcrumb {...stickyProps} background="$background" class="nav" w="$full">
<For each={paths()}>
{(name, i) => {
const isLast = createMemo(() => i() === paths().length - 1)
Expand Down
2 changes: 1 addition & 1 deletion src/pages/home/Readme.tsx
Expand Up @@ -60,7 +60,7 @@ export function Readme(props: {
<Show when={readme()}>
<Box w="$full" rounded="$xl" p="$4" bgColor={cardBg()} shadow="$lg">
<MaybeLoading loading={content.loading}>
<Markdown children={content()?.content} readme />
<Markdown children={content()?.content} readme toc />
</MaybeLoading>
</Box>
</Show>
Expand Down
17 changes: 15 additions & 2 deletions src/pages/home/header/Header.tsx
Expand Up @@ -5,9 +5,10 @@ import {
Center,
Icon,
Kbd,
CenterProps,
} from "@hope-ui/solid"
import { Show } from "solid-js"
import { getSetting, objStore, State } from "~/store"
import { Show, createMemo } from "solid-js"
import { getSetting, local, objStore, State } from "~/store"
import { BsSearch } from "solid-icons/bs"
import { CenterLoading } from "~/components"
import { Container } from "../Container"
Expand All @@ -18,8 +19,20 @@ import { isMac } from "~/utils/compatibility"
export const Header = () => {
const logos = getSetting("logo").split("\n")
const logo = useColorModeValue(logos[0], logos.pop())

const stickyProps = createMemo<CenterProps>(() => {
switch (local["position_of_header_navbar"]) {
case "sticky":
return { position: "sticky", zIndex: "$sticky", top: 0 }
default:
return { position: undefined, zIndex: undefined, top: undefined }
}
})

return (
<Center
{...stickyProps}
bgColor="$background"
class="header"
w="$full"
// shadow="$md"
Expand Down
6 changes: 5 additions & 1 deletion src/pages/home/previews/markdown.tsx
Expand Up @@ -7,7 +7,11 @@ const MdPreview = () => {
const [content] = useFetchText()
return (
<MaybeLoading loading={content.loading}>
<Markdown children={content()?.content} ext={ext(objStore.obj.name)} />
<Markdown
children={content()?.content}
ext={ext(objStore.obj.name)}
toc
/>
</MaybeLoading>
)
}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/home/previews/markdown_with_word_wrap.tsx
Expand Up @@ -5,7 +5,7 @@ const MdPreview = () => {
const [content] = useFetchText()
return (
<MaybeLoading loading={content.loading}>
<Markdown class="word-wrap" children={content()?.content} />
<Markdown class="word-wrap" children={content()?.content} toc />
</MaybeLoading>
)
}
Expand Down
58 changes: 58 additions & 0 deletions src/pages/home/toolbar/BackTop.tsx
@@ -0,0 +1,58 @@
import { Show, createSignal, onCleanup } from "solid-js"
import { Box, Icon } from "@hope-ui/solid"
import { FiArrowUp } from "solid-icons/fi"
import { Motion } from "@motionone/solid"
import { isMobile } from "~/utils/compatibility"
import { getMainColor } from "~/store"

export const useScrollListener = (
callback: (e?: Event) => void,
options?: { immediate?: boolean },
) => {
if (options?.immediate) callback()
window.addEventListener("scroll", callback, { passive: true })
onCleanup(() => window.removeEventListener("scroll", callback))
}

export const BackTop = () => {
if (isMobile) return null

const [visible, setVisible] = createSignal(false)

useScrollListener(() => setVisible(window.scrollY > 100))

return (
<Show when={visible()}>
<Box
as={Motion.div}
initial={{ y: -999 }}
animate={{ y: 0 }}
zIndex="$overlay"
pos="fixed"
right="$5"
top="0"
borderBottomRadius="50%"
bgColor="$whiteAlpha12"
color={getMainColor()}
overflow="hidden"
shadow="$lg"
_dark={{ bgColor: getMainColor(), color: "white" }}
_hover={{ bgColor: getMainColor(), color: "white" }}
>
<Icon
_focus={{
outline: "none",
}}
cursor="pointer"
boxSize="$7"
p="$1"
rounded="$lg"
as={FiArrowUp}
onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" })
}}
/>
</Box>
</Show>
)
}

0 comments on commit 045ce1d

Please sign in to comment.