Improve breadcrumb and code block controls

This commit is contained in:
2026-05-29 15:08:44 +08:00
parent cf2d5fc77b
commit eb73d41d35
7 changed files with 465 additions and 54 deletions
@@ -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.
+147 -1
View File
@@ -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 = ['<span class="toolbar-kicker">Docs</span>'];
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('<span class="toolbar-divider">/</span>');
segments.push(`<span class="toolbar-segment">${escapeHtml(part)}</span>`);
});
if (fileTitle) {
segments.push('<span class="toolbar-divider">/</span>');
segments.push(`<span class="toolbar-segment is-current">${escapeHtml(fileTitle)}</span>`);
}
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) {
+124 -13
View File
@@ -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 {
+11 -3
View File
@@ -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);
+11 -3
View File
@@ -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);
+68 -31
View File
@@ -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,
+1 -3
View File
@@ -28,10 +28,8 @@
</aside>
<main class="content">
<div class="content-toolbar">
<div class="toolbar-breadcrumb">
<div class="toolbar-breadcrumb" id="toolbarBreadcrumb" aria-label="当前文档路径">
<span class="toolbar-kicker">Docs</span>
<span class="toolbar-divider">/</span>
<span class="toolbar-title">{{SITE_TITLE}}</span>
</div>
<div class="toolbar-actions">
<label class="theme-select-wrap" for="themeSelect" title="切换主题包">