From 10f8eee25c5c0dfa5891d27ec319979e1d9cca1f Mon Sep 17 00:00:00 2001 From: matei jordache Date: Tue, 31 Mar 2026 18:54:57 -0700 Subject: [PATCH] add toc, markdown jump, default docs --- backend/server.py | 24 ++++ frontend/src/App.jsx | 52 ++++++++- frontend/src/HelpPanelManager.jsx | 186 +++++++++++++++++++++++++++--- frontend/src/styles.css | 78 ++++++++++++- frontend/vite.config.js | 1 + 5 files changed, 320 insertions(+), 21 deletions(-) diff --git a/backend/server.py b/backend/server.py index 7776b8b..25291cd 100644 --- a/backend/server.py +++ b/backend/server.py @@ -265,6 +265,28 @@ def create_app( content_type="text/plain", ) + async def get_help_docs(request: web.Request) -> web.Response: + public_dir = FRONTEND_DIR / "public" + if not public_dir.is_dir(): + return web.json_response([]) + files = sorted(p.name for p in public_dir.iterdir() if p.suffix.lower() == ".md" and p.is_file()) + result = [] + for fname in files: + text = (public_dir / fname).read_text(encoding="utf-8", errors="replace") + title = fname.rsplit(".", 1)[0].replace("-", " ").replace("_", " ").title() + result.append({"title": title, "content": text}) + return web.json_response(result) + + async def get_help_doc_file(request: web.Request) -> web.Response: + filename = request.match_info["filename"] + public_dir = FRONTEND_DIR / "public" + path = (public_dir / filename).resolve() + if not str(path).startswith(str(public_dir.resolve())) or not path.is_file(): + return web.Response(status=404, text="Not found") + text = path.read_text(encoding="utf-8", errors="replace") + title = filename.rsplit(".", 1)[0].replace("-", " ").replace("_", " ").title() + return web.json_response({"title": title, "content": text}) + async def get_nodes(request: web.Request) -> web.Response: return web.Response( text=_dumps(get_all_node_info()), @@ -545,6 +567,8 @@ def create_app( app.router.add_post("/save-workflow-png", save_workflow_png) app.router.add_get("/channels", get_channels) app.router.add_get("/docs", get_node_doc) + app.router.add_get("/help-docs", get_help_docs) + app.router.add_get("/help-docs/{filename}", get_help_doc_file) app.router.add_post("/prompt", submit_prompt) app.router.add_get("/ws", websocket_handler) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a96220..72a6879 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1970,6 +1970,24 @@ function Flow() { setHelpTabs((prev) => prev.map((t) => t.label === label ? { ...t, content } : t)); }, []); + const openDocByFilename = useCallback(async (filename) => { + const title = filename.replace(/\.md$/i, '').replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + // If already open, just switch to it + setHelpTabs((prev) => { + if (prev.find((t) => t.label === title)) return prev; + return [...prev, { label: title, content: null }]; + }); + setActiveHelpTab(title); + try { + const r = await fetch(`/help-docs/${encodeURIComponent(filename)}`); + if (!r.ok) throw new Error('Not found'); + const doc = await r.json(); + setHelpTabs((prev) => prev.map((t) => t.label === title ? { ...t, content: doc.content } : t)); + } catch { + setHelpTabs((prev) => prev.map((t) => t.label === title ? { ...t, content: `*Could not load ${filename}.*` } : t)); + } + }, []); + const contextValue = useMemo(() => ({ onWidgetChange, onRuntimeValuesChange, @@ -1996,9 +2014,18 @@ function Flow() { setEdges(hydrated.edges); nextIdRef.current = hydrated.nextNodeId; journalContentRef.current = data.journalContent || ''; - setHelpTabs((prev) => prev.map((t) => - t.label === 'Journal' ? { ...t, content: journalContentRef.current } : t, - )); + if (journalContentRef.current) { + setHelpTabs((prev) => { + const existing = prev.find((t) => t.label === 'Journal'); + if (existing) return prev.map((t) => t.label === 'Journal' ? { ...t, content: journalContentRef.current } : t); + return [...prev, { label: 'Journal', type: 'journal', content: journalContentRef.current }]; + }); + setActiveHelpTab('Journal'); + } else { + setHelpTabs((prev) => prev.map((t) => + t.label === 'Journal' ? { ...t, content: '' } : t, + )); + } initializeDynamicNodes(hydrated.nodes); }, [initializeDynamicNodes, setNodes, setEdges]); @@ -2038,6 +2065,20 @@ function Flow() { }).catch((err) => { setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' }); }); + + // Load any .md files from frontend/public/ as help tabs + fetch('/help-docs') + .then((r) => r.ok ? r.json() : []) + .then((docs) => { + if (!docs.length) return; + setHelpTabs((prev) => { + const existing = new Set(prev.map((t) => t.label)); + const newTabs = docs.filter((d) => !existing.has(d.title)).map((d) => ({ label: d.title, content: d.content })); + return newTabs.length ? [...prev, ...newTabs] : prev; + }); + setActiveHelpTab((cur) => cur || docs[0].title); + }) + .catch(() => {}); }, [loadDefaultWorkflow]); const stampLogoOnBlob = useCallback(async (blob) => { @@ -2055,8 +2096,8 @@ function Flow() { ctx.drawImage(img, 0, 0); const margin = 16; - const size = Math.min(128, Math.floor(img.naturalWidth / 6), Math.floor(img.naturalHeight / 6)); - if (size >= 16) { + const size = 64; + if (img.naturalWidth >= size + margin * 2 && img.naturalHeight >= size + margin * 2) { const logoX = img.naturalWidth - size - margin; const logoY = img.naturalHeight - size - margin; const fontSize = Math.max(11, Math.round(size * 0.18)); @@ -3011,6 +3052,7 @@ function Flow() { onTabClose={closeHelpTab} onTabContentChange={updateTabContent} onOpenJournal={openJournalTab} + onOpenDoc={openDocByFilename} /> ); diff --git a/frontend/src/HelpPanelManager.jsx b/frontend/src/HelpPanelManager.jsx index 07380cc..6c4d7b6 100644 --- a/frontend/src/HelpPanelManager.jsx +++ b/frontend/src/HelpPanelManager.jsx @@ -1,13 +1,165 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { marked } from 'marked'; -function JournalTab({ content, onChange }) { +// ── Parse headings from markdown source ────────────────────────────── + +function parseHeadings(md) { + if (!md) return []; + const headings = []; + const lines = md.split('\n'); + for (const line of lines) { + const m = line.match(/^(#{1,6})\s+(.+)/); + if (m) { + const text = m[2].replace(/[*_`~\[\]]/g, '').trim(); + const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, ''); + headings.push({ level: m[1].length, text, id }); + } + } + return headings; +} + +// ── Inject id attributes into rendered HTML headings ───────────────── + +function injectHeadingIds(html, headings) { + let idx = 0; + return html.replace(/<(h[1-6])>/gi, (match, tag) => { + if (idx < headings.length) { + return `<${tag} id="${headings[idx++].id}">`; + } + return match; + }); +} + +// ── Build a tree from flat heading list ────────────────────────────── + +function buildTocTree(headings) { + const root = { children: [] }; + const stack = [{ node: root, level: 0 }]; + for (const h of headings) { + const item = { ...h, children: [] }; + while (stack.length > 1 && stack[stack.length - 1].level >= h.level) stack.pop(); + stack[stack.length - 1].node.children.push(item); + stack.push({ node: item, level: h.level }); + } + return root.children; +} + +// ── TOC sidebar component ──────────────────────────────────────────── + +function TocItem({ item, collapsed, onToggle, onNavigate }) { + const hasChildren = item.children.length > 0; + const isCollapsed = collapsed[item.id]; + return ( +
  • +
    + {hasChildren ? ( + + ) : ( + + )} + { e.preventDefault(); onNavigate(item.id); }} + > + {item.text} + +
    + {hasChildren && !isCollapsed && ( + + )} +
  • + ); +} + +function Toc({ headings, contentRef }) { + const [collapsed, setCollapsed] = useState({}); + const tree = useMemo(() => buildTocTree(headings), [headings]); + + const onToggle = (id) => setCollapsed((prev) => ({ ...prev, [id]: !prev[id] })); + + const onNavigate = (id) => { + const el = contentRef.current?.querySelector(`#${CSS.escape(id)}`); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + if (tree.length === 0) return null; + + return ( + + ); +} + +// ── Click handler for .md links ────────────────────────────────────── + +function useMdLinkHandler(onOpenDoc) { + return (e) => { + const a = e.target.closest('a[href]'); + if (!a) return; + const href = a.getAttribute('href'); + if (href && /\.md$/i.test(href) && !href.startsWith('http')) { + e.preventDefault(); + const filename = href.split('/').pop(); + onOpenDoc(filename); + } + }; +} + +// ── Content pane with TOC ──────────────────────────────────────────── + +function HelpContent({ content, onOpenDoc }) { + const contentRef = useRef(null); + const handleClick = useMdLinkHandler(onOpenDoc); + const md = content || '*Loading…*'; + const headings = useMemo(() => parseHeadings(md), [md]); + const html = useMemo(() => { + let rendered; + try { rendered = marked.parse(md); } catch { rendered = md; } + return injectHeadingIds(rendered, headings); + }, [md, headings]); + + return ( +
    + +
    +
    + ); +} + +// ── Journal tab ────────────────────────────────────────────────────── + +function JournalTab({ content, onChange, onOpenDoc }) { const [isEditing, setIsEditing] = useState(false); + const contentRef = useRef(null); + const handleClick = useMdLinkHandler(onOpenDoc); let renderedHtml = ''; + let headings = []; if (!isEditing && content?.trim()) { - try { renderedHtml = marked.parse(content); } catch { /* fallback to raw */ renderedHtml = content; } + headings = parseHeadings(content); + try { renderedHtml = injectHeadingIds(marked.parse(content), headings); } catch { renderedHtml = content; } } return ( @@ -39,12 +191,17 @@ function JournalTab({ content, onChange }) { autoFocus /> ) : renderedHtml ? ( -
    setIsEditing(true)} - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ __html: renderedHtml }} - /> +
    + +
    setIsEditing(true)} + onClick={handleClick} + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: renderedHtml }} + /> +
    ) : (
    { @@ -116,13 +275,10 @@ function HelpPanelManager({ tabs, activeTab, onTabSelect, onTabClose, onTabConte onTabContentChange(active.label, val)} + onOpenDoc={onOpenDoc} /> ) : ( -
    + ) )}
    , diff --git a/frontend/src/styles.css b/frontend/src/styles.css index acf36c1..2aa4745 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -566,7 +566,7 @@ html, body, #root { position: fixed; top: 60px; right: 20px; - width: 420px; + width: 620px; max-height: calc(100vh - 80px); background: #1e293b; border: 1px solid #334155; @@ -700,6 +700,82 @@ html, body, #root { .node-help-panel-body strong { color: #e2e8f0; } +/* ── Help panel TOC + content layout ──────────────────────────────── */ + +.help-content-row { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.help-content-row > .node-help-panel-body { + flex: 1; + min-width: 0; +} + +.help-toc { + width: 160px; + flex-shrink: 0; + overflow-y: auto; + border-right: 1px solid #1e293b; + padding: 8px 0; + font-size: 11px; + background: #0f172a; +} + +.help-toc-root { padding: 0; } + +.help-toc-list { + list-style: none; + margin: 0; + padding: 0 0 0 10px; +} + +.help-toc-item { + margin: 0; +} + +.help-toc-row { + display: flex; + align-items: baseline; + gap: 2px; +} + +.help-toc-arrow { + background: none; + border: none; + color: #475569; + font-size: 7px; + padding: 0; + width: 12px; + flex-shrink: 0; + cursor: pointer; + text-align: center; + line-height: 1; + transition: color 0.12s; +} +.help-toc-arrow:hover { color: #94a3b8; } + +.help-toc-arrow-spacer { + display: inline-block; + width: 12px; + flex-shrink: 0; +} + +.help-toc-link { + color: #94a3b8; + text-decoration: none; + padding: 2px 6px 2px 0; + display: block; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: color 0.12s; +} +.help-toc-link:hover { color: #f1f5f9; } + .node-body { padding: 4px 0; display: flex; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 0bd43f4..918b666 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -19,6 +19,7 @@ export default defineConfig({ '/upload-folder': 'http://127.0.0.1:8188', '/upload': 'http://127.0.0.1:8188', '/download': 'http://127.0.0.1:8188', + '/help-docs': { target: 'http://127.0.0.1:8188', changeOrigin: true }, '/prompt': 'http://127.0.0.1:8188', '/ws': { target: 'http://127.0.0.1:8188',