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: