From 045ce1dd67718d63b96c07905484a6ea03d7682c Mon Sep 17 00:00:00 2001 From: liuycy Date: Mon, 5 Feb 2024 20:49:46 +0800 Subject: [PATCH] feat: sticky header & markdown toc (#141) * 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 --- src/components/Markdown.tsx | 147 +++++++++++++++++- src/lang/en/home.json | 7 + src/pages/home/Nav.tsx | 36 ++++- src/pages/home/Readme.tsx | 2 +- src/pages/home/header/Header.tsx | 17 +- src/pages/home/previews/markdown.tsx | 6 +- .../home/previews/markdown_with_word_wrap.tsx | 2 +- src/pages/home/toolbar/BackTop.tsx | 58 +++++++ src/pages/home/toolbar/Right.tsx | 18 ++- src/pages/home/toolbar/Toolbar.tsx | 3 + src/store/local_settings.ts | 6 + 11 files changed, 286 insertions(+), 16 deletions(-) create mode 100644 src/pages/home/toolbar/BackTop.tsx diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 5ecf5a9b0..65ff35b5c 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -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() +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([]) + + 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 ( + + + + + + {(item) => ( + + handleAnchor(item)} + > + {item.text} + + + )} + + + + + + ) +} const insertKatexCSS = once(() => { const link = document.createElement("link") @@ -27,6 +161,7 @@ export function Markdown(props: { class?: string ext?: string readme?: boolean + toc?: boolean }) { const [encoding, setEncoding] = createSignal("utf-8") const [show, setShow] = createSignal(true) @@ -77,7 +212,12 @@ export function Markdown(props: { }), ) return ( - + setMarkdownRef(r)} + class="markdown" + pos="relative" + w="$full" + > + ) } diff --git a/src/lang/en/home.json b/src/lang/en/home.json index 28ef6ef6e..38353267d 100644 --- a/src/lang/en/home.json +++ b/src/lang/en/home.json @@ -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", @@ -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": { diff --git a/src/pages/home/Nav.tsx b/src/pages/home/Nav.tsx index 0aaddde8d..8c5109211 100644 --- a/src/pages/home/Nav.tsx +++ b/src/pages/home/Nav.tsx @@ -2,12 +2,13 @@ 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 = () => { @@ -15,8 +16,39 @@ export const Nav = () => { const paths = createMemo(() => ["", ...pathname().split("/").filter(Boolean)]) const t = useT() const { setPathAs } = usePath() + + const stickyProps = createMemo(() => { + 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 ( - + {(name, i) => { const isLast = createMemo(() => i() === paths().length - 1) diff --git a/src/pages/home/Readme.tsx b/src/pages/home/Readme.tsx index 63548a011..90742ed84 100644 --- a/src/pages/home/Readme.tsx +++ b/src/pages/home/Readme.tsx @@ -60,7 +60,7 @@ export function Readme(props: { - + diff --git a/src/pages/home/header/Header.tsx b/src/pages/home/header/Header.tsx index e6726a15c..4ab44aa87 100644 --- a/src/pages/home/header/Header.tsx +++ b/src/pages/home/header/Header.tsx @@ -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" @@ -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(() => { + switch (local["position_of_header_navbar"]) { + case "sticky": + return { position: "sticky", zIndex: "$sticky", top: 0 } + default: + return { position: undefined, zIndex: undefined, top: undefined } + } + }) + return (
{ const [content] = useFetchText() return ( - + ) } diff --git a/src/pages/home/previews/markdown_with_word_wrap.tsx b/src/pages/home/previews/markdown_with_word_wrap.tsx index 83e729998..ad325a511 100644 --- a/src/pages/home/previews/markdown_with_word_wrap.tsx +++ b/src/pages/home/previews/markdown_with_word_wrap.tsx @@ -5,7 +5,7 @@ const MdPreview = () => { const [content] = useFetchText() return ( - + ) } diff --git a/src/pages/home/toolbar/BackTop.tsx b/src/pages/home/toolbar/BackTop.tsx new file mode 100644 index 000000000..c9bdaa9a3 --- /dev/null +++ b/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 ( + + + { + window.scrollTo({ top: 0, behavior: "smooth" }) + }} + /> + + + ) +} diff --git a/src/pages/home/toolbar/Right.tsx b/src/pages/home/toolbar/Right.tsx index 2a9338b55..7d48fbe0b 100644 --- a/src/pages/home/toolbar/Right.tsx +++ b/src/pages/home/toolbar/Right.tsx @@ -1,9 +1,4 @@ -import { - Box, - createDisclosure, - useColorModeValue, - VStack, -} from "@hope-ui/solid" +import { Box, createDisclosure, VStack } from "@hope-ui/solid" import { createMemo, Show } from "solid-js" import { RightIcon } from "./Icon" import { CgMoreO } from "solid-icons/cg" @@ -16,6 +11,8 @@ import { AiOutlineCloudUpload, AiOutlineSetting } from "solid-icons/ai" import { RiSystemRefreshLine } from "solid-icons/ri" import { usePath } from "~/hooks" import { Motion } from "@motionone/solid" +import { isTocVisible, setTocDisabled } from "~/components" +import { BiSolidBookContent } from "solid-icons/bi" export const Right = () => { const { isOpen, onToggle } = createDisclosure({ @@ -124,6 +121,15 @@ export const Right = () => { }} /> + + { + setTocDisabled((disabled) => !disabled) + }} + /> + import("../uploads/Upload")) export const Modal = () => { @@ -44,6 +46,7 @@ export const Toolbar = () => {
+ ) } diff --git a/src/store/local_settings.ts b/src/store/local_settings.ts index fead91731..c6c04bb23 100644 --- a/src/store/local_settings.ts +++ b/src/store/local_settings.ts @@ -29,6 +29,12 @@ export const initialLocalSettings = [ type: "select", options: ["top", "bottom", "none"], }, + { + key: "position_of_header_navbar", + default: "static", + type: "select", + options: ["static", "sticky", "only_navbar_sticky"], + }, ] export type LocalSetting = (typeof initialLocalSettings)[number] for (const setting of initialLocalSettings) {