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: