diff --git a/docs/superpowers/specs/2026-05-29-toolbar-breadcrumb-design.md b/docs/superpowers/specs/2026-05-29-toolbar-breadcrumb-design.md new file mode 100644 index 0000000..e4bffbb --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-toolbar-breadcrumb-design.md @@ -0,0 +1,103 @@ +# Toolbar Breadcrumb Design + +## Goal + +Replace the static toolbar title with a breadcrumb that reflects the current document location. + +Target format: + +`Docs / 目录1 / 目录2 / 文件标题` + +The final segment uses the current file title instead of the raw file name. + +## Scope + +In scope: + +- Update the toolbar breadcrumb display in the reading view. +- Derive breadcrumb segments from the current markdown file path. +- Use the resolved markdown title for the last segment. +- Keep the breadcrumb display-only for now. + +Out of scope: + +- Clicking breadcrumb segments to navigate. +- Renaming directory segments with custom labels. +- Changing sidebar navigation behavior. + +## Data Source + +The client already has the data needed for breadcrumb rendering: + +- `currentFile` tracks the selected markdown file path. +- `navTree` contains file metadata, including titles extracted from markdown. +- `initialContent` and `defaultFile` cover first-load rendering. + +No server-side schema changes are required for this feature. + +## Behavior + +### First load + +- If the page loads with an initial document, render the breadcrumb immediately. +- If the page loads at `/` and auto-selects a homepage, render the breadcrumb for that file. + +### On file change + +- When a new markdown file finishes loading, refresh the toolbar breadcrumb. +- Keep the final segment synced with the rendered file title. + +### Segment rules + +- Always start with `Docs`. +- For files in the root directory, show `Docs / 文件标题`. +- For nested files, show each directory name in order, followed by the file title. +- If a file has no H1 title, fall back to the file name without extension. + +### Failure behavior + +- If a hash points to a file that fails to load, do not replace the breadcrumb with a broken path. +- Keep the most recent valid breadcrumb until a valid file is rendered. + +## Implementation Plan + +### Template + +Replace the single static toolbar title node with a breadcrumb container that the client can fully control. + +### Client logic + +Add two small helpers in `public/app.js`: + +- A title index builder that maps file paths to their resolved display titles from `navTree`. +- A breadcrumb renderer that splits `currentFile` into directory segments and appends the resolved file title as the last segment. + +Call the breadcrumb renderer in both places that establish visible content: + +- Initial content hydration +- Successful `loadFile()` completion + +### Styling + +Keep the existing toolbar visual style, but allow breadcrumb segments to wrap the available width more gracefully: + +- Directory segments may truncate first. +- The final file-title segment should remain most visible. +- Mobile layout should continue to fit within the existing toolbar structure. + +## Testing + +Verify these cases: + +- Root path auto-loads a homepage and shows `Docs / 文件标题` +- Root-level markdown file selection +- Nested markdown file selection +- Direct refresh on a deep hash URL +- Markdown file without an H1 title +- Narrow viewport layout does not break the toolbar + +## Review Notes + +- Breadcrumb remains display-only by design. +- Directory labels come from real folder names, not markdown metadata. +- This change is intentionally client-side only to keep the diff narrow. diff --git a/public/app.js b/public/app.js index 8d61fd7..8f5263e 100644 --- a/public/app.js +++ b/public/app.js @@ -13,6 +13,7 @@ const btnToggleSidebar = document.getElementById('btnToggleSidebar'); const btnToggleTheme = document.getElementById('btnToggleTheme'); const themeSelect = document.getElementById('themeSelect'); + const toolbarBreadcrumb = document.getElementById('toolbarBreadcrumb'); const contentBody = document.getElementById('contentBody'); const emptyState = document.getElementById('emptyState'); const markdownContent = document.getElementById('markdownContent'); @@ -23,11 +24,13 @@ const tocPanel = document.getElementById('tocPanel'); const loadingBar = document.getElementById('loadingBar'); const sidebar = document.getElementById('sidebar'); + const app = document.getElementById('app'); const mobileNavBtn = document.getElementById('mobileNavBtn'); const themeStylesheet = document.getElementById('themeStylesheet'); const themeHighlightStylesheet = document.getElementById('themeHighlightStylesheet'); let currentFile = null; + const titleByPath = buildTitleIndex(navTree); const fallbackTheme = themes[0] || { id: 'default', label: 'Default', @@ -115,10 +118,12 @@ function setSidebarVisible(visible) { if (visible) { sidebar.classList.remove('collapsed'); + app.classList.remove('sidebar-collapsed'); localStorage.setItem('webook-sidebar', 'visible'); btnToggleSidebar.textContent = '☰'; } else { sidebar.classList.add('collapsed'); + app.classList.add('sidebar-collapsed'); localStorage.setItem('webook-sidebar', 'hidden'); btnToggleSidebar.textContent = '☷'; } @@ -197,6 +202,63 @@ return div.innerHTML; } + function buildTitleIndex(items, index = {}) { + if (!Array.isArray(items)) return index; + + items.forEach((item) => { + if (item.type === 'file') { + const title = item.meta && item.meta.title + ? item.meta.title + : item.name.replace(/\.(md|markdown)$/i, ''); + index[item.path] = title; + return; + } + + if (item.type === 'dir' && Array.isArray(item.children)) { + buildTitleIndex(item.children, index); + } + }); + + return index; + } + + function getFileDisplayTitle(filePath, preferredTitle) { + if (preferredTitle) return preferredTitle; + if (titleByPath[filePath]) return titleByPath[filePath]; + if (!filePath) return ''; + const fileName = filePath.split('/').pop() || filePath; + return fileName.replace(/\.(md|markdown)$/i, ''); + } + + function renderToolbarBreadcrumb(filePath, preferredTitle) { + if (!toolbarBreadcrumb) return; + + const segments = ['Docs']; + const parts = typeof filePath === 'string' && filePath + ? filePath.split('/').filter(Boolean) + : []; + + if (!parts.length) { + toolbarBreadcrumb.innerHTML = segments.join(''); + return; + } + + const directoryParts = parts.slice(0, -1); + const fileTitle = getFileDisplayTitle(filePath, preferredTitle); + + directoryParts.forEach((part) => { + segments.push('/'); + segments.push(`${escapeHtml(part)}`); + }); + + if (fileTitle) { + segments.push('/'); + segments.push(`${escapeHtml(fileTitle)}`); + } + + toolbarBreadcrumb.innerHTML = segments.join(''); + } + // Render nav sidebarNav.innerHTML = renderNavTree(navTree); @@ -308,7 +370,6 @@ async function loadFile(filePath) { if (currentFile === filePath) return; - currentFile = filePath; showLoading(); try { @@ -325,11 +386,14 @@ emptyState.style.display = 'none'; markdownContent.style.display = ''; + currentFile = filePath; articleTitle.textContent = data.title; articleBody.innerHTML = data.html; + enhanceCodeBlocks(); buildTOC(); renderArticleMeta(data.meta); + renderToolbarBreadcrumb(filePath, data.title); // Scroll to top contentBody.scrollTop = 0; @@ -379,6 +443,77 @@ // For now, server-side rendering handles highlighting. } + function enhanceCodeBlocks() { + const blocks = articleBody.querySelectorAll('pre.hljs'); + blocks.forEach((block) => { + if (block.parentElement && block.parentElement.classList.contains('code-block-shell')) return; + + const shell = document.createElement('div'); + shell.className = 'code-block-shell'; + block.parentNode.insertBefore(shell, block); + shell.appendChild(block); + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'code-copy-btn'; + button.textContent = '复制'; + button.setAttribute('aria-label', '复制代码'); + shell.appendChild(button); + }); + } + + async function writeClipboardText(text) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + let copied = false; + try { + copied = document.execCommand('copy'); + } finally { + textarea.remove(); + } + + if (!copied) { + throw new Error('copy command failed'); + } + + return true; + } + + async function copyCodeFromButton(button) { + const shell = button.closest('.code-block-shell'); + const block = shell ? shell.querySelector('pre') : null; + const code = block ? block.querySelector('code') : null; + if (!code) return; + + const text = code.textContent || ''; + + try { + await writeClipboardText(text); + button.textContent = '已复制'; + } catch (_) { + button.textContent = '复制失败'; + } + + window.setTimeout(() => { + if (button.isConnected) { + button.textContent = '复制'; + } + }, 1600); + } + function renderArticleMeta(meta) { const created = formatDate(meta.created); const modified = formatDate(meta.modified); @@ -442,11 +577,13 @@ markdownContent.style.display = ''; articleTitle.textContent = initialContent.title; articleBody.innerHTML = initialContent.html; + enhanceCodeBlocks(); buildTOC(); renderArticleMeta(initialContent.meta); currentFile = defaultFile; + renderToolbarBreadcrumb(defaultFile, initialContent.title); if (defaultFile && history.replaceState) { history.replaceState(null, '', '#' + encodeURIComponent(defaultFile)); } @@ -472,6 +609,7 @@ } window.addEventListener('hashchange', handleHashChange); + handleHashChange(); // === Event Listeners === btnToggleTheme.addEventListener('click', toggleColorMode); @@ -490,6 +628,14 @@ scrollToHeading(link.getAttribute('data-target')); }); } + if (articleBody) { + articleBody.addEventListener('click', function (e) { + const button = e.target.closest('.code-copy-btn'); + if (!button) return; + e.preventDefault(); + copyCodeFromButton(button); + }); + } // === Keyboard Shortcuts === document.addEventListener('keydown', function (e) { diff --git a/public/style.css b/public/style.css index 7df636c..a7f20e2 100644 --- a/public/style.css +++ b/public/style.css @@ -14,6 +14,10 @@ --brand-soft: rgba(58, 111, 247, 0.12); --code-bg: #0f172a; --code-fg: #e5edf9; + --code-toolbar-bg: #f4f7fb; + --code-panel-border: rgba(148, 163, 184, 0.24); + --code-panel-shadow: 0 18px 40px rgba(148, 163, 184, 0.18); + --code-line-number: #94a3b8; --shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.08); --shadow-md: 0 10px 28px rgba(15, 23, 42, 0.08); --shadow-sm: 0 4px 16px rgba(15, 23, 42, 0.06); @@ -51,6 +55,11 @@ a:hover { color: var(--brand-strong); } display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); height: 100vh; + transition: grid-template-columns var(--transition); +} + +.app.sidebar-collapsed { + grid-template-columns: 0 minmax(0, 1fr); } .sidebar { @@ -63,8 +72,18 @@ a:hover { color: var(--brand-strong); } background: var(--bg); backdrop-filter: none; box-shadow: none; + overflow: hidden; + transition: + margin-left var(--transition), + opacity var(--transition), + visibility var(--transition); +} +.sidebar.collapsed { + margin-left: calc(-1 * var(--sidebar-width)); + opacity: 0; + visibility: hidden; + pointer-events: none; } -.sidebar.collapsed { margin-left: calc(-1 * var(--sidebar-width)); } .sidebar-header { display: grid; gap: 14px; padding-bottom: 14px; } .sidebar-brand { display: flex; align-items: center; gap: 12px; @@ -167,14 +186,41 @@ a:hover { color: var(--brand-strong); } border-bottom: 0; backdrop-filter: none; } -.toolbar-breadcrumb { display: flex; align-items: center; gap: 10px; min-width: 0; } +.toolbar-breadcrumb { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; + overflow: hidden; +} .toolbar-kicker { display: inline-flex; align-items: center; padding: 5px 10px; border-radius: 999px; background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 700; + flex: 0 0 auto; +} +.toolbar-divider, +.toolbar-segment { + color: var(--text-muted); + font-size: 14px; + flex: 0 1 auto; + min-width: 0; +} +.toolbar-divider { + flex: 0 0 auto; +} +.toolbar-segment { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.toolbar-segment.is-current { + color: var(--text); + font-weight: 600; + flex-shrink: 0; + max-width: min(42vw, 420px); } -.toolbar-divider, .toolbar-title { color: var(--text-muted); font-size: 14px; } -.toolbar-title { color: var(--text); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .toolbar-actions { display: flex; align-items: center; gap: 8px; } .theme-select-wrap { display: inline-flex; @@ -295,15 +341,70 @@ a:hover { color: var(--brand-strong); } padding: 0.18em 0.42em; border-radius: 8px; background: rgba(148, 163, 184, 0.14); color: var(--text); } -.article-body pre { - margin: 1.2em 0; padding: 18px 20px; - border-radius: 20px; overflow: auto; - background: var(--code-bg) !important; color: var(--code-fg); - border: 1px solid rgba(148, 163, 184, 0.12); - box-shadow: var(--shadow-md); +.code-block-shell { + position: relative; + margin: 1.2em 0; } -.article-body pre code { - padding: 0; background: transparent; color: inherit; font-size: 0.92rem; +.article-body .code-block-shell pre { + margin: 0; + padding: 52px 20px 18px; + border-radius: 20px; + overflow: auto; + color: var(--code-fg); + border: 1px solid var(--code-panel-border); + box-shadow: var(--code-panel-shadow); + background: + radial-gradient(circle at 24px 18px, #ff5f57 0 5px, transparent 5.5px), + radial-gradient(circle at 44px 18px, #febc2e 0 5px, transparent 5.5px), + radial-gradient(circle at 64px 18px, #28c840 0 5px, transparent 5.5px), + linear-gradient(180deg, var(--code-toolbar-bg) 0 36px, var(--code-bg) 36px 100%) !important; +} +.article-body .code-block-shell pre code { + padding: 0; + background: transparent; + color: inherit; + font-size: 0.92rem; + line-height: 1.6; +} +.code-copy-btn { + position: absolute; + top: 10px; + right: 12px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--code-panel-border); + border-radius: 9px; + background: rgba(255, 255, 255, 0.78); + color: var(--text-muted); + font: inherit; + font-size: 12px; + font-weight: 700; + line-height: 1; + cursor: pointer; + backdrop-filter: blur(10px); + opacity: 0; + pointer-events: none; + transition: opacity var(--transition), color var(--transition), background var(--transition), border-color var(--transition); +} +.code-copy-btn:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.94); + border-color: var(--border-strong); +} +.code-block-shell:hover .code-copy-btn, +.code-block-shell:focus-within .code-copy-btn { + opacity: 1; + pointer-events: auto; +} +[data-color-mode="light"] .article-body .code-block-shell pre .hljs-ln-n { + color: var(--code-line-number); +} +[data-color-mode="dark"] .code-copy-btn { + background: rgba(15, 23, 42, 0.78); + color: var(--text-muted); +} +[data-color-mode="dark"] .code-copy-btn:hover { + background: rgba(30, 41, 59, 0.94); } .article-body table { width: 100%; margin: 1.2em 0; border-collapse: collapse; @@ -404,11 +505,17 @@ a:hover { color: var(--brand-strong); } .sidebar { position: fixed; inset: 0 auto 0 0; z-index: 220; width: min(86vw, 320px); transform: translateX(-100%); + visibility: visible; + opacity: 1; transition: transform 220ms ease; box-shadow: var(--shadow-lg); } .sidebar.mobile-visible { transform: translateX(0); } - .sidebar.collapsed { margin-left: 0; } + .sidebar.collapsed { + margin-left: 0; + visibility: visible; + opacity: 1; + } .content-toolbar { padding-left: 18px; } .toolbar-breadcrumb { min-width: 0; } .theme-select-wrap { max-width: 150px; } @@ -432,6 +539,10 @@ a:hover { color: var(--brand-strong); } width: 92px; padding: 0 10px; } + .code-copy-btn { + opacity: 1; + pointer-events: auto; + } } [data-doc-theme="toc"] .content { diff --git a/public/themes/default/theme.css b/public/themes/default/theme.css index 42b795c..862ad7a 100644 --- a/public/themes/default/theme.css +++ b/public/themes/default/theme.css @@ -12,8 +12,12 @@ html[data-theme-id="default"][data-color-mode="light"] { --brand: #3a6ff7; --brand-strong: #2757d6; --brand-soft: rgba(58, 111, 247, 0.12); - --code-bg: #0f172a; - --code-fg: #e5edf9; + --code-bg: #fbfdff; + --code-fg: #233044; + --code-toolbar-bg: #eef3f9; + --code-panel-border: rgba(148, 163, 184, 0.28); + --code-panel-shadow: 0 20px 44px rgba(148, 163, 184, 0.18); + --code-line-number: #9aa9bc; --shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.08); --shadow-md: 0 10px 28px rgba(15, 23, 42, 0.08); --shadow-sm: 0 4px 16px rgba(15, 23, 42, 0.06); @@ -42,8 +46,12 @@ html[data-theme-id="default"][data-color-mode="dark"] { --brand: #7aa2ff; --brand-strong: #93b4ff; --brand-soft: rgba(122, 162, 255, 0.14); - --code-bg: #020617; + --code-bg: #142033; --code-fg: #dbeafe; + --code-toolbar-bg: #1b2a40; + --code-panel-border: rgba(148, 163, 184, 0.16); + --code-panel-shadow: 0 14px 32px rgba(2, 6, 23, 0.22); + --code-line-number: #6f8099; --shadow-lg: 0 24px 60px rgba(2, 6, 23, 0.34); --shadow-md: 0 14px 32px rgba(2, 6, 23, 0.28); --shadow-sm: 0 6px 18px rgba(2, 6, 23, 0.2); diff --git a/public/themes/paper/theme.css b/public/themes/paper/theme.css index 9765aba..663abab 100644 --- a/public/themes/paper/theme.css +++ b/public/themes/paper/theme.css @@ -12,8 +12,12 @@ html[data-theme-id="paper"][data-color-mode="light"] { --brand: #8a5a2b; --brand-strong: #6f451d; --brand-soft: rgba(138, 90, 43, 0.12); - --code-bg: #2b2118; - --code-fg: #f8efe3; + --code-bg: #fffaf4; + --code-fg: #4a3422; + --code-toolbar-bg: #f4e8da; + --code-panel-border: rgba(138, 90, 43, 0.22); + --code-panel-shadow: 0 18px 38px rgba(122, 77, 38, 0.12); + --code-line-number: #b09072; --shadow-lg: 0 18px 42px rgba(77, 52, 29, 0.1); --shadow-md: 0 10px 24px rgba(77, 52, 29, 0.08); --shadow-sm: 0 4px 14px rgba(77, 52, 29, 0.06); @@ -42,8 +46,12 @@ html[data-theme-id="paper"][data-color-mode="dark"] { --brand: #d1a16c; --brand-strong: #e0b88c; --brand-soft: rgba(209, 161, 108, 0.14); - --code-bg: #120d09; + --code-bg: #2a1f16; --code-fg: #f8efe3; + --code-toolbar-bg: #3a2b20; + --code-panel-border: rgba(205, 173, 141, 0.16); + --code-panel-shadow: 0 14px 30px rgba(0, 0, 0, 0.18); + --code-line-number: #8f7762; --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.32); --shadow-md: 0 14px 30px rgba(0, 0, 0, 0.24); --shadow-sm: 0 6px 16px rgba(0, 0, 0, 0.18); diff --git a/src/server.js b/src/server.js index f493a07..6e46a51 100644 --- a/src/server.js +++ b/src/server.js @@ -103,6 +103,72 @@ function renderPage({ title, nav, docTheme, customThemeHref, themes, initialThem }); } +function normalizeEntryName(name) { + return String(name) + .replace(/\.(md|markdown)$/i, '') + .toLowerCase() + .replace(/[\s\-_.]+/g, ''); +} + +function selectDefaultFile(nav, rootDir) { + const rootFiles = Array.isArray(nav) + ? nav.filter((item) => item.type === 'file') + : []; + + if (rootFiles.length === 0) { + return null; + } + + const rootDirName = normalizeEntryName(path.basename(rootDir)); + const matchers = [ + (name) => name.includes('index'), + (name) => name.includes('首页'), + (name) => rootDirName && (name.includes(rootDirName) || rootDirName.includes(name)), + (name) => name.includes('readme') + ]; + + for (const matcher of matchers) { + const matched = rootFiles.find((item) => matcher(normalizeEntryName(item.name))); + if (matched) { + return matched.path; + } + } + + return rootFiles[0].path; +} + +function buildInitialContent(renderer, rootDir, relativeFilePath) { + if (!relativeFilePath) { + return null; + } + + const fullPath = path.join(rootDir, relativeFilePath); + if (!fs.existsSync(fullPath)) { + return null; + } + + const content = fs.readFileSync(fullPath, 'utf-8'); + const env = { filePath: relativeFilePath }; + const html = renderer.render(stripLeadingTitle(content), env); + const stats = fs.statSync(fullPath); + const match = content.match(/^#\s+(.+)$/m); + const fileName = path.basename(relativeFilePath); + const pageTitle = match ? match[1].trim() : fileName.replace(/\.(md|markdown)$/i, ''); + const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length; + const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length; + + return { + title: pageTitle, + html, + meta: { + created: stats.birthtime.toISOString(), + modified: stats.mtime.toISOString(), + wordCount: chineseChars + englishWords, + fileName + } + }; +} + /** * Create and start the webook server */ @@ -205,37 +271,8 @@ function createServer(opts) { app.get('/', (req, res) => { try { const nav = scanDirectory(dir); - // Try to find a README.md or index.md as default - const defaultFiles = ['README.md', 'readme.md', 'index.md', 'INDEX.md']; - let initialContent = null; - let defaultFile = null; - - for (const df of defaultFiles) { - const dfPath = path.join(dir, df); - if (fs.existsSync(dfPath)) { - defaultFile = df; - const content = fs.readFileSync(dfPath, 'utf-8'); - const env = { filePath: df }; - const html = renderer.render(stripLeadingTitle(content), env); - const stats = fs.statSync(dfPath); - const match = content.match(/^#\s+(.+)$/m); - const dfTitle = match ? match[1].trim() : df.replace(/\.md$/i, ''); - const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length; - const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length; - - initialContent = { - title: dfTitle, - html, - meta: { - created: stats.birthtime.toISOString(), - modified: stats.mtime.toISOString(), - wordCount: chineseChars + englishWords, - fileName: df - } - }; - break; - } - } + const defaultFile = selectDefaultFile(nav, dir); + const initialContent = buildInitialContent(renderer, dir, defaultFile); res.send(renderPage({ title, diff --git a/views/page.html b/views/page.html index 26009fa..db42869 100644 --- a/views/page.html +++ b/views/page.html @@ -28,10 +28,8 @@ - + Docs - / - {{SITE_TITLE}}