From 072730f7ebe9edca34604bb26b01297ffff17be1 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Wed, 4 Sep 2024 21:15:39 -0700 Subject: [PATCH] Table of contents for markdown preview (#323) Adds a table of contents in the markdown preview, with a button in the header to toggle whether to show the TOC. When a user clicks one of the TOC elements, the preview will scroll to the corresponding heading. I've also cleaned up some MD preview styling that was inconsistent and causing the preview to overflow unnecessarily. This also fixes some terminology in the preview code. image --- frontend/app/block/block.tsx | 7 +- frontend/app/element/markdown.less | 291 +++++++++++++---------- frontend/app/element/markdown.tsx | 91 +++++-- frontend/app/theme.less | 2 +- frontend/app/view/helpview/helpview.less | 2 +- frontend/app/view/helpview/helpview.tsx | 38 ++- frontend/app/view/preview/preview.less | 3 +- frontend/app/view/preview/preview.tsx | 24 +- package.json | 1 + yarn.lock | 18 +- 10 files changed, 321 insertions(+), 156 deletions(-) diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 1656a830f..8491abbb7 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -12,7 +12,7 @@ import * as WOS from "@/store/wos"; import { getElemAsStr } from "@/util/focusutil"; import * as util from "@/util/util"; import { CpuPlotView, CpuPlotViewModel, makeCpuPlotViewModel } from "@/view/cpuplot/cpuplot"; -import { HelpView } from "@/view/helpview/helpview"; +import { HelpView, HelpViewModel, makeHelpViewModel } from "@/view/helpview/helpview"; import { TermViewModel, TerminalView, makeTerminalModel } from "@/view/term/term"; import { WaveAi, WaveAiModel, makeWaveAiViewModel } from "@/view/waveai/waveai"; import { WebView, WebViewModel, makeWebViewModel } from "@/view/webview/webview"; @@ -44,6 +44,9 @@ function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel) if (blockView === "cpuplot") { return makeCpuPlotViewModel(blockId); } + if (blockView === "help") { + return makeHelpViewModel(); + } return makeDefaultViewModel(blockId, blockView); } @@ -84,7 +87,7 @@ function getViewElem( return ; } if (blockView == "help") { - return ; + return ; } return Invalid View "{blockView}"; } diff --git a/frontend/app/element/markdown.less b/frontend/app/element/markdown.less index 557780e61..8a266239e 100644 --- a/frontend/app/element/markdown.less +++ b/frontend/app/element/markdown.less @@ -2,148 +2,187 @@ // SPDX-License-Identifier: Apache-2.0 .markdown { - color: var(--app-text-color); - font-family: var(--markdown-font); - font-size: 14px; - overflow-wrap: break-word; - margin-bottom: 10px; - - .title { + display: flex; + flex-direction: row; + overflow: hidden; + height: 100%; + width: 100%; + .content { + height: 100%; + width: 100%; + overflow: scroll; + line-height: 1.5; color: var(--app-text-color); - margin-top: 16px; - margin-bottom: 8px; - } + font-family: var(--markdown-font); + font-size: 14px; + overflow-wrap: break-word; + margin-bottom: 10px; + --half-contents-height: 10em; - strong { - color: var(--app-text-color); - } + .heading { + &:first-of-type { + margin-top: 0 !important; + } + color: var(--app-text-color); + margin-top: 16px; + margin-bottom: 8px; + scroll-margin-block-end: var(--half-contents-height); + } - a { - color: #32afff; - } - - table { - tr th { + strong { color: var(--app-text-color); } - } - ul { - list-style-type: disc; - list-style-position: outside; - margin-left: 16px; - } - - ol { - list-style-position: outside; - margin-left: 19px; - } - - blockquote { - margin: 4px 10px 4px 10px; - border-radius: 3px; - background-color: var(--panel-bg-color); - padding: 2px 4px 2px 6px; - } - - pre.codeblock { - background-color: var(--panel-bg-color); - margin: 4px 10px; - padding: 0.4em 0.7em; - border-radius: 4px; - position: relative; - - code { - line-height: 1.5; - white-space: pre-wrap; - word-wrap: break-word; - overflow: auto; - overflow: hidden; + a { + color: #32afff; } - .codeblock-actions { - visibility: hidden; - display: flex; - position: absolute; - top: 4px; - right: 4px; - border-radius: 4px; - align-items: center; - justify-content: flex-end; - padding-left: 4px; - padding-right: 4px; - - i { - color: var(--line-actions-inactive-color); - margin-left: 4px; - - &:first-child { - margin-left: 0px; - } - - &:hover { - color: var(--line-actions-active-color); - } - - &.fa-check { - color: var(--success-color); - } - - &.fa-square-terminal { - cursor: pointer; - } + table { + tr th { + color: var(--app-text-color); } } - &:hover .codeblock-actions { - visibility: visible; + ul { + list-style-type: disc; + list-style-position: outside; + margin-left: 16px; + } + + ol { + list-style-position: outside; + margin-left: 19px; + } + + blockquote { + margin: 4px 10px 4px 10px; + border-radius: 3px; + background-color: var(--panel-bg-color); + padding: 2px 4px 2px 6px; + } + + pre.codeblock { + background-color: var(--panel-bg-color); + margin: 4px 10px; + padding: 0.4em 0.7em; + border-radius: 4px; + position: relative; + + code { + line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; + overflow: auto; + overflow: hidden; + } + + .codeblock-actions { + visibility: hidden; + display: flex; + position: absolute; + top: 4px; + right: 4px; + border-radius: 4px; + align-items: center; + justify-content: flex-end; + padding-left: 4px; + padding-right: 4px; + + i { + color: var(--line-actions-inactive-color); + margin-left: 4px; + + &:first-child { + margin-left: 0px; + } + + &:hover { + color: var(--line-actions-active-color); + } + + &.fa-check { + color: var(--success-color); + } + + &.fa-square-terminal { + cursor: pointer; + } + } + } + + &:hover .codeblock-actions { + visibility: visible; + } + } + + code { + color: var(--app-text-color); + background-color: var(--panel-bg-color); + font-family: var(--termfontfamily); + border-radius: 4px; + } + + pre.selected { + outline: 2px solid var(--accent-color); + } + + .heading { + font-weight: semibold; + padding-top: 6px; + } + + .heading.is-1 { + border-bottom: 1px solid var(--border-color); + padding-bottom: 6px; + font-size: 2em; + } + .heading.is-2 { + border-bottom: 1px solid var(--border-color); + padding-bottom: 6px; + font-size: 1.5em; + } + .heading.is-3 { + font-size: 1.25em; + } + .heading.is-4 { + font-size: 1em; + } + .heading.is-5 { + font-size: 0.875em; + } + .heading.is-6 { + font-size: 0.85em; } } - code { - color: var(--app-text-color); - background-color: var(--panel-bg-color); - font-family: var(--termfontfamily); - border-radius: 4px; - } + // The TOC view should scroll independently of the contents view. + .toc { + max-width: 40%; + height: 100%; + overflow: scroll; + border-left: 1px solid var(--border-color); + .toc-inner { + height: fit-content; + position: sticky; + top: 0; + display: flex; + flex-direction: column; + gap: 5px; + text-wrap: wrap; - pre.selected { - outline: 2px solid var(--accent-color); - } + h4 { + padding-left: 5px; + } - .title { - font-weight: semibold; - padding-top: 6px; - } + .toc-item { + cursor: pointer; + --indent-factor: 1; - .title.is-1 { - border-bottom: 1px solid #777; - padding-bottom: 6px; - font-size: 2em; - } - .title.is-2 { - border-bottom: 1px solid #777; - padding-bottom: 6px; - font-size: 1.5em; - } - .title.is-3 { - font-size: 1.25em; - } - .title.is-4 { - font-size: 1em; - } - .title.is-5 { - font-size: 0.875em; - } - .title.is-6 { - font-size: 0.85em; + // The 5px offset in the padding will ensure that when the text in the item wraps, it indents slightly. + // The indent factor is set in the React code and denotes the depth of the item in the TOC tree. + padding-left: calc((var(--indent-factor) - 1) * 10px + 5px); + text-indent: -5px; + } + } } } - -.markdown.content { - line-height: 1.5; -} - -.markdown > *:first-child { - margin-top: 0 !important; -} diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index dedf15413..a65c89dcb 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -3,11 +3,15 @@ import { CopyButton } from "@/app/element/copybutton"; import { clsx } from "clsx"; -import React from "react"; +import React, { CSSProperties, useCallback, useMemo, useRef } from "react"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; +import { Atom, useAtomValue } from "jotai"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc"; +import { useHeight } from "../hook/useHeight"; import "./markdown.less"; const Link = ({ href, children }: { href: string; children: React.ReactNode }) => { @@ -19,8 +23,8 @@ const Link = ({ href, children }: { href: string; children: React.ReactNode }) = ); }; -const Header = ({ children, hnum }: { children: React.ReactNode; hnum: number }) => { - return
{children}
; +const Heading = ({ children, hnum }: { children: React.ReactNode; hnum: number }) => { + return
{children}
; }; const Code = ({ children }: { children: React.ReactNode }) => { @@ -71,30 +75,87 @@ const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => { }; type MarkdownProps = { - text: string; + textAtom: Atom | Atom>; + showTocAtom: Atom; style?: React.CSSProperties; className?: string; onClickExecute?: (cmd: string) => void; }; -const Markdown = ({ text, style, className, onClickExecute }: MarkdownProps) => { +const Markdown = ({ textAtom, showTocAtom, style, className, onClickExecute }: MarkdownProps) => { + const text = useAtomValue(textAtom); + const tocRef = useRef([]); + const showToc = useAtomValue(showTocAtom); + const contentsRef = useRef(null); + const contentsHeight = useHeight(contentsRef, 200); + + const halfContentsHeight = useMemo(() => { + return `${contentsHeight / 2}px`; + }, [contentsHeight]); + + const onTocClick = useCallback((data: string) => { + if (contentsRef.current) { + const headings = contentsRef.current.getElementsByClassName("heading"); + for (const heading of headings) { + if (heading.textContent === data) { + heading.scrollIntoView({ inline: "nearest", block: "end" }); + } + } + } + }, []); + const markdownComponents = { a: Link, - h1: (props: any) =>
, - h2: (props: any) =>
, - h3: (props: any) =>
, - h4: (props: any) =>
, - h5: (props: any) =>
, - h6: (props: any) =>
, + h1: (props: any) => , + h2: (props: any) => , + h3: (props: any) => , + h4: (props: any) => , + h5: (props: any) => , + h6: (props: any) => , code: Code, pre: (props: any) => , }; + const toc = useMemo(() => { + if (showToc && tocRef.current.length > 0) { + return tocRef.current.map((item) => { + return ( + onTocClick(item.value)} + > + {item.value} + + ); + }); + } + }, [showToc, tocRef]); + return ( -
- - {text} - +
+ + + {text} + + + {showToc && ( + +
+

Table of Contents

+ {toc} +
+
+ )}
); }; diff --git a/frontend/app/theme.less b/frontend/app/theme.less index 6980fceb8..e5201a751 100644 --- a/frontend/app/theme.less +++ b/frontend/app/theme.less @@ -7,7 +7,7 @@ --secondary-text-color: rgb(195, 200, 194); --grey-text-color: #666; --main-bg-color: rgb(34, 34, 34); - --border-color: rgba(255, 255, 255, 0.08); + --border-color: rgba(255, 255, 255, 0.16); --base-font: normal 14px / normal "Inter", sans-serif; --fixed-font: normal 12px / normal "Hack", monospace; --accent-color: rgb(88, 193, 66); diff --git a/frontend/app/view/helpview/helpview.less b/frontend/app/view/helpview/helpview.less index 037532dc0..0c5048f47 100644 --- a/frontend/app/view/helpview/helpview.less +++ b/frontend/app/view/helpview/helpview.less @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 .help-view { - overflow-y: auto; width: 100%; + padding: 0 5px; .title { font-weight: bold; diff --git a/frontend/app/view/helpview/helpview.tsx b/frontend/app/view/helpview/helpview.tsx index e4e9211b3..449a0a8e2 100644 --- a/frontend/app/view/helpview/helpview.tsx +++ b/frontend/app/view/helpview/helpview.tsx @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { Markdown } from "@/app/element/markdown"; - +import { globalStore } from "@/app/store/global"; +import { Atom, atom, PrimitiveAtom } from "jotai"; import "./helpview.less"; const helpText = ` @@ -179,8 +180,37 @@ Other useful metadata values to override block titles, icons, colors, themes, et `; -function HelpView({ blockId }: { blockId: string }) { - return ; +const helpTextAtom = atom(helpText); + +class HelpViewModel implements ViewModel { + viewType: string; + showTocAtom: PrimitiveAtom; + endIconButtons: Atom; + + constructor() { + this.viewType = "help"; + this.showTocAtom = atom(false); + this.endIconButtons = atom([ + { + elemtype: "iconbutton", + icon: "book", + title: "Table of Contents", + click: () => this.showTocToggle(), + }, + ] as HeaderIconButton[]); + } + + showTocToggle() { + globalStore.set(this.showTocAtom, !globalStore.get(this.showTocAtom)); + } } -export { HelpView }; +function makeHelpViewModel() { + return new HelpViewModel(); +} + +function HelpView({ model }: { model: HelpViewModel }) { + return ; +} + +export { HelpView, HelpViewModel, makeHelpViewModel }; diff --git a/frontend/app/view/preview/preview.less b/frontend/app/view/preview/preview.less index ddfdb683d..58f924827 100644 --- a/frontend/app/view/preview/preview.less +++ b/frontend/app/view/preview/preview.less @@ -4,13 +4,12 @@ .view-preview { display: flex; flex-direction: row; - width: 100%; height: 100%; flex-grow: 1; overflow: hidden; align-items: center; justify-content: center; - margin: 5px; + padding: 0 5px; &.view-preview-markdown { align-items: start; diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 264c875e0..8f1048ed1 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -116,6 +116,8 @@ export class PreviewModel implements ViewModel { openFileError: jotai.PrimitiveAtom; openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>; + markdownShowToc: jotai.PrimitiveAtom; + monacoRef: React.MutableRefObject; showHiddenFiles: jotai.PrimitiveAtom; @@ -140,6 +142,7 @@ export class PreviewModel implements ViewModel { this.openFileModalGiveFocusRef = createRef(); this.manageConnection = jotai.atom(true); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.markdownShowToc = jotai.atom(false); this.monacoRef = createRef(); this.viewIcon = jotai.atom((get) => { const blockData = get(this.blockAtom); @@ -196,6 +199,7 @@ export class PreviewModel implements ViewModel { headerPath = `~ (${loadableFileInfo.data?.dir})`; } } + const viewTextChildren: HeaderElem[] = [ { elemtype: "text", @@ -256,6 +260,8 @@ export class PreviewModel implements ViewModel { }); this.endIconButtons = jotai.atom((get) => { const mimeType = util.jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); + const loadableSV = get(this.loadableSpecializedView); + const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit"; if (mimeType == "directory") { const showHiddenFiles = get(this.showHiddenFiles); return [ @@ -271,7 +277,16 @@ export class PreviewModel implements ViewModel { icon: "arrows-rotate", click: () => this.refreshCallback?.(), }, - ]; + ] as HeaderIconButton[]; + } else if (!isCeView && mimeType.startsWith("text/markdown")) { + return [ + { + elemtype: "iconbutton", + icon: "book", + title: "Table of Contents", + click: () => this.markdownShowTocToggle(), + }, + ] as HeaderIconButton[]; } return null; }); @@ -343,6 +358,10 @@ export class PreviewModel implements ViewModel { this.loadableFileInfo = loadable(this.statFile); } + markdownShowTocToggle() { + globalStore.set(this.markdownShowToc, !globalStore.get(this.markdownShowToc)); + } + async getSpecializedView(getFn: jotai.Getter): Promise<{ specializedView?: string; errorStr?: string }> { const mimeType = await getFn(this.fileMimeType); const fileInfo = await getFn(this.statFile); @@ -637,10 +656,9 @@ function makePreviewModel(blockId: string, nodeModel: NodeModel): PreviewModel { } function MarkdownPreview({ model }: SpecializedViewProps) { - const readmeText = jotai.useAtomValue(model.fileContent); return (
- +
); } diff --git a/package.json b/package.json index 4fd81c668..34c20f0fd 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "react-gauge-chart": "^0.5.1", "react-markdown": "^9.0.1", "rehype-raw": "^7.0.0", + "remark-flexible-toc": "^1.1.1", "remark-gfm": "^4.0.0", "rxjs": "^7.8.1", "shell-quote": "^1.8.1", diff --git a/yarn.lock b/yarn.lock index 6cebdffc3..c1a5e02db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3528,7 +3528,7 @@ __metadata: languageName: node linkType: hard -"@types/mdast@npm:^4.0.0": +"@types/mdast@npm:^4.0.0, @types/mdast@npm:^4.0.4": version: 4.0.4 resolution: "@types/mdast@npm:4.0.4" dependencies: @@ -10744,6 +10744,19 @@ __metadata: languageName: node linkType: hard +"remark-flexible-toc@npm:^1.1.1": + version: 1.1.1 + resolution: "remark-flexible-toc@npm:1.1.1" + dependencies: + "@types/mdast": "npm:^4.0.4" + github-slugger: "npm:^2.0.0" + mdast-util-to-string: "npm:^4.0.0" + unified: "npm:^11.0.5" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/e1dfaaa6635b94c23835e3f0fb2f71affda5f08bcfc4e385c2972b4fa6c5217253022d2da4de09c6df8b8a0bcb56b0b787268c0f73f714feeb69a4c685b2117c + languageName: node + linkType: hard + "remark-gfm@npm:^4.0.0": version: 4.0.0 resolution: "remark-gfm@npm:4.0.0" @@ -11755,6 +11768,7 @@ __metadata: react-gauge-chart: "npm:^0.5.1" react-markdown: "npm:^9.0.1" rehype-raw: "npm:^7.0.0" + remark-flexible-toc: "npm:^1.1.1" remark-gfm: "npm:^4.0.0" rollup-plugin-flow: "npm:^1.1.1" rxjs: "npm:^7.8.1" @@ -12167,7 +12181,7 @@ __metadata: languageName: node linkType: hard -"unified@npm:^11.0.0": +"unified@npm:^11.0.0, unified@npm:^11.0.5": version: 11.0.5 resolution: "unified@npm:11.0.5" dependencies: