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.

<img width="574" alt="image"
src="https://github.com/user-attachments/assets/abb18ba9-21d3-4315-bdc3-e4bdcca39a4c">
This commit is contained in:
Evan Simkowitz 2024-09-04 21:15:39 -07:00 committed by GitHub
parent 0fae6981f8
commit 072730f7eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 321 additions and 156 deletions

View File

@ -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 <CpuPlotView key={blockId} blockId={blockId} model={viewModel as CpuPlotViewModel} />;
}
if (blockView == "help") {
return <HelpView key={blockId} blockId={blockId} />;
return <HelpView key={blockId} model={viewModel as HelpViewModel} />;
}
return <CenteredDiv>Invalid View "{blockView}"</CenteredDiv>;
}

View File

@ -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;
}

View File

@ -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 <div className={clsx("title", `is-${hnum}`)}>{children}</div>;
const Heading = ({ children, hnum }: { children: React.ReactNode; hnum: number }) => {
return <div className={clsx("heading", `is-${hnum}`)}>{children}</div>;
};
const Code = ({ children }: { children: React.ReactNode }) => {
@ -71,30 +75,87 @@ const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => {
};
type MarkdownProps = {
text: string;
textAtom: Atom<string> | Atom<Promise<string>>;
showTocAtom: Atom<boolean>;
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<TocItem[]>([]);
const showToc = useAtomValue(showTocAtom);
const contentsRef = useRef<HTMLDivElement>(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) => <Header {...props} hnum={1} />,
h2: (props: any) => <Header {...props} hnum={2} />,
h3: (props: any) => <Header {...props} hnum={3} />,
h4: (props: any) => <Header {...props} hnum={4} />,
h5: (props: any) => <Header {...props} hnum={5} />,
h6: (props: any) => <Header {...props} hnum={6} />,
h1: (props: any) => <Heading {...props} hnum={1} />,
h2: (props: any) => <Heading {...props} hnum={2} />,
h3: (props: any) => <Heading {...props} hnum={3} />,
h4: (props: any) => <Heading {...props} hnum={4} />,
h5: (props: any) => <Heading {...props} hnum={5} />,
h6: (props: any) => <Heading {...props} hnum={6} />,
code: Code,
pre: (props: any) => <CodeBlock {...props} onClickExecute={onClickExecute} />,
};
const toc = useMemo(() => {
if (showToc && tocRef.current.length > 0) {
return tocRef.current.map((item) => {
return (
<a
key={item.href}
className="toc-item"
style={{ "--indent-factor": item.depth } as CSSProperties}
onClick={() => onTocClick(item.value)}
>
{item.value}
</a>
);
});
}
}, [showToc, tocRef]);
return (
<div className={clsx("markdown content", className)} style={style}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={markdownComponents}>
{text}
</ReactMarkdown>
<div className={clsx("markdown", className)} style={style} ref={contentsRef}>
<OverlayScrollbarsComponent
className="content"
style={{ "--half-contents-height": halfContentsHeight } as CSSProperties}
options={{ scrollbars: { autoHide: "leave" } }}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, [RemarkFlexibleToc, { tocRef: tocRef.current }]]}
rehypePlugins={[rehypeRaw]}
components={markdownComponents}
>
{text}
</ReactMarkdown>
</OverlayScrollbarsComponent>
{showToc && (
<OverlayScrollbarsComponent className="toc" options={{ scrollbars: { autoHide: "leave" } }}>
<div className="toc-inner">
<h4>Table of Contents</h4>
{toc}
</div>
</OverlayScrollbarsComponent>
)}
</div>
);
};

View File

@ -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);

View File

@ -2,8 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
.help-view {
overflow-y: auto;
width: 100%;
padding: 0 5px;
.title {
font-weight: bold;

View File

@ -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 <Markdown text={helpText} className="help-view" />;
const helpTextAtom = atom(helpText);
class HelpViewModel implements ViewModel {
viewType: string;
showTocAtom: PrimitiveAtom<boolean>;
endIconButtons: Atom<HeaderIconButton[]>;
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 <Markdown textAtom={helpTextAtom} showTocAtom={model.showTocAtom} className="help-view" />;
}
export { HelpView, HelpViewModel, makeHelpViewModel };

View File

@ -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;

View File

@ -116,6 +116,8 @@ export class PreviewModel implements ViewModel {
openFileError: jotai.PrimitiveAtom<string>;
openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>;
markdownShowToc: jotai.PrimitiveAtom<boolean>;
monacoRef: React.MutableRefObject<MonacoTypes.editor.IStandaloneCodeEditor>;
showHiddenFiles: jotai.PrimitiveAtom<boolean>;
@ -140,6 +142,7 @@ export class PreviewModel implements ViewModel {
this.openFileModalGiveFocusRef = createRef();
this.manageConnection = jotai.atom(true);
this.blockAtom = WOS.getWaveObjectAtom<Block>(`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 (
<div className="view-preview view-preview-markdown">
<Markdown text={readmeText} />
<Markdown textAtom={model.fileContent} showTocAtom={model.markdownShowToc} />
</div>
);
}

View File

@ -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",

View File

@ -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: