From 99602c5297299e93371ea26bbc23900ef30c07f2 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 28 Sep 2026 13:43:46 +0000 Subject: [PATCH 1/5] refactor(detail): move SKILL.md structure helpers to utils/markdownDoc Pure move of splitFrontmatter / extractHeadings / slugifyHeading / parseFrontmatterPairs out of SkillDetail.jsx, plus unit tests pinning their current behaviour. Co-Authored-By: Claude Opus 5.5 --- client/scripts/markdown-doc.test.mjs | 41 +++++++++++++++++ client/src/components/SkillDetail.jsx | 64 +-------------------------- client/src/utils/markdownDoc.js | 64 +++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 63 deletions(-) create mode 100644 client/scripts/markdown-doc.test.mjs create mode 100644 client/src/utils/markdownDoc.js diff --git a/client/scripts/markdown-doc.test.mjs b/client/scripts/markdown-doc.test.mjs new file mode 100644 index 0000000..d9f69ac --- /dev/null +++ b/client/scripts/markdown-doc.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { extractHeadings, parseFrontmatterPairs, slugifyHeading, splitFrontmatter } from '../src/utils/markdownDoc.js'; + +test('splitFrontmatter separates the leading --- block from the body', () => { + assert.deepEqual(splitFrontmatter('---\nname: a\n---\n# Title'), { frontmatter: 'name: a', body: '# Title' }); + assert.deepEqual(splitFrontmatter('# No frontmatter'), { frontmatter: '', body: '# No frontmatter' }); + assert.deepEqual(splitFrontmatter('---\nunterminated'), { frontmatter: '', body: '---\nunterminated' }); + assert.deepEqual(splitFrontmatter(''), { frontmatter: '', body: '' }); +}); + +test('extractHeadings keeps h1-h4 and skips headings inside fenced code', () => { + const md = ['# One', '```bash', '# not a heading', '```', '## Two ##', '~~~', '### hidden', '~~~', '##### five', '#### Four'].join('\n'); + assert.deepEqual(extractHeadings(md), [ + { level: 1, text: 'One' }, + { level: 2, text: 'Two' }, + { level: 4, text: 'Four' }, + ]); +}); + +test('extractHeadings only closes a fence with the same marker', () => { + const md = ['```', '~~~', '# inside', '```', '# after'].join('\n'); + assert.deepEqual(extractHeadings(md), [{ level: 1, text: 'after' }]); +}); + +test('slugifyHeading keeps unicode letters and de-duplicates within one document', () => { + const used = new Set(); + assert.equal(slugifyHeading('Hello, World!', used), 'hello-world'); + assert.equal(slugifyHeading('Hello World', used), 'hello-world-2'); + assert.equal(slugifyHeading('使用 说明', used), '使用-说明'); + assert.equal(slugifyHeading('!!!', used), 'section'); +}); + +test('parseFrontmatterPairs reads key: value lines and strips surrounding quotes', () => { + assert.deepEqual(parseFrontmatterPairs('name: "demo"\ndescription: \'x y\'\n - list item\nversion: 1'), [ + { key: 'name', value: 'demo' }, + { key: 'description', value: 'x y' }, + { key: 'version', value: '1' }, + ]); + assert.deepEqual(parseFrontmatterPairs(''), []); +}); diff --git a/client/src/components/SkillDetail.jsx b/client/src/components/SkillDetail.jsx index aaa7718..efebbef 100644 --- a/client/src/components/SkillDetail.jsx +++ b/client/src/components/SkillDetail.jsx @@ -26,6 +26,7 @@ import { showToast } from './toastBus'; import useResizableWidth from '../hooks/useResizableWidth'; import { requestJson } from '../utils/requestJson'; import { formatDateTime, relativeTime } from '../utils/date'; +import { extractHeadings, parseFrontmatterPairs, slugifyHeading, splitFrontmatter } from '../utils/markdownDoc'; import { Check, ChevronDown, @@ -56,69 +57,6 @@ const OUTLINE_MIN_VIEWPORT = '(min-width: 1280px)'; const HEADING_SELECTOR = '.markdown-document h1, .markdown-document h2, .markdown-document h3, .markdown-document h4'; -function splitFrontmatter(text) { - if (!text) return { frontmatter: '', body: '' }; - const trimmed = text.trim(); - if (!trimmed.startsWith('---')) return { frontmatter: '', body: trimmed }; - const end = trimmed.indexOf('---', 3); - if (end === -1) return { frontmatter: '', body: trimmed }; - return { - frontmatter: trimmed.slice(3, end).trim(), - body: trimmed.slice(end + 3).trim(), - }; -} - -function extractHeadings(markdown) { - const lines = markdown.split('\n'); - const headings = []; - let inFence = false; - let fenceMarker = ''; - for (const line of lines) { - const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); - if (fenceMatch) { - if (!inFence) { - inFence = true; - fenceMarker = fenceMatch[1][0]; - } else if (fenceMatch[1][0] === fenceMarker) { - inFence = false; - } - continue; - } - if (inFence) continue; - const match = line.match(/^(#{1,4})\s+(.+?)\s*#*\s*$/); - if (match) { - headings.push({ level: match[1].length, text: match[2].trim() }); - } - } - return headings; -} - -function slugifyHeading(text, used) { - const base = text - .toLowerCase() - .replace(/[^\p{L}\p{N}\s-]/gu, '') - .trim() - .replace(/\s+/g, '-') || 'section'; - let slug = base; - let index = 2; - while (used.has(slug)) { - slug = `${base}-${index}`; - index += 1; - } - used.add(slug); - return slug; -} - -// B5: frontmatter 解析为键值对表格 -function parseFrontmatterPairs(raw) { - if (!raw) return []; - return raw.split('\n') - .map((line) => line.match(/^([\w-]+)\s*:\s*(.*)$/)) - .filter(Boolean) - .map((match) => ({ key: match[1], value: match[2].replace(/^["']|["']$/g, '').trim() })) - .filter((pair) => pair.key); -} - function buildFileTree(files) { const root = { name: '', path: '', children: new Map(), file: null }; for (const file of files) { diff --git a/client/src/utils/markdownDoc.js b/client/src/utils/markdownDoc.js new file mode 100644 index 0000000..e785155 --- /dev/null +++ b/client/src/utils/markdownDoc.js @@ -0,0 +1,64 @@ +// SKILL.md 文档结构的纯函数:frontmatter 拆分 / 解析、标题提取与锚点 slug。不依赖 DOM,可直接 node --test。 + +export function splitFrontmatter(text) { + if (!text) return { frontmatter: '', body: '' }; + const trimmed = text.trim(); + if (!trimmed.startsWith('---')) return { frontmatter: '', body: trimmed }; + const end = trimmed.indexOf('---', 3); + if (end === -1) return { frontmatter: '', body: trimmed }; + return { + frontmatter: trimmed.slice(3, end).trim(), + body: trimmed.slice(end + 3).trim(), + }; +} + +export function extractHeadings(markdown) { + const lines = markdown.split('\n'); + const headings = []; + let inFence = false; + let fenceMarker = ''; + for (const line of lines) { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); + if (fenceMatch) { + if (!inFence) { + inFence = true; + fenceMarker = fenceMatch[1][0]; + } else if (fenceMatch[1][0] === fenceMarker) { + inFence = false; + } + continue; + } + if (inFence) continue; + const match = line.match(/^(#{1,4})\s+(.+?)\s*#*\s*$/); + if (match) { + headings.push({ level: match[1].length, text: match[2].trim() }); + } + } + return headings; +} + +export function slugifyHeading(text, used) { + const base = text + .toLowerCase() + .replace(/[^\p{L}\p{N}\s-]/gu, '') + .trim() + .replace(/\s+/g, '-') || 'section'; + let slug = base; + let index = 2; + while (used.has(slug)) { + slug = `${base}-${index}`; + index += 1; + } + used.add(slug); + return slug; +} + +// B5: frontmatter 解析为键值对表格 +export function parseFrontmatterPairs(raw) { + if (!raw) return []; + return raw.split('\n') + .map((line) => line.match(/^([\w-]+)\s*:\s*(.*)$/)) + .filter(Boolean) + .map((match) => ({ key: match[1], value: match[2].replace(/^["']|["']$/g, '').trim() })) + .filter((pair) => pair.key); +} From 3eda475ee96992f3554d917d649305b3be00408b Mon Sep 17 00:00:00 2001 From: john Date: Mon, 28 Sep 2026 13:44:15 +0000 Subject: [PATCH 2/5] refactor(detail): move markdown rendering and highlighting to utils/highlight marked/hljs setup, languageForPath, highlightCode, splitHighlightedLines, the render LRU and renderMarkdown move out of SkillDetail.jsx unchanged. Only the lazily loaded detail chunk imports it, so the entry bundle size is unchanged (app 100.14 kB before and after). Co-Authored-By: Claude Opus 5.5 --- client/scripts/highlight.test.mjs | 38 +++++++ client/src/components/SkillDetail.jsx | 142 +------------------------ client/src/utils/highlight.js | 143 ++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 141 deletions(-) create mode 100644 client/scripts/highlight.test.mjs create mode 100644 client/src/utils/highlight.js diff --git a/client/scripts/highlight.test.mjs b/client/scripts/highlight.test.mjs new file mode 100644 index 0000000..c5693b6 --- /dev/null +++ b/client/scripts/highlight.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { cachedRender, highlightCode, languageForPath, splitHighlightedLines } from '../src/utils/highlight.js'; + +test('languageForPath maps common skill-bundle extensions and ignores unknown ones', () => { + assert.equal(languageForPath('scripts/run.sh'), 'bash'); + assert.equal(languageForPath('a/b/c.TSX'), 'typescript'); + assert.equal(languageForPath('config.toml'), 'ini'); + assert.equal(languageForPath('LICENSE'), null); + assert.equal(languageForPath('image.png'), null); +}); + +test('highlightCode escapes unknown languages instead of auto-detecting', () => { + assert.equal(highlightCode(' & b', null), '<a> & b'); + assert.equal(highlightCode('', 'bash'), ''); + assert.match(highlightCode('const x = 1;', 'javascript'), /const<\/span>/); +}); + +test('splitHighlightedLines closes and reopens spans that cross line breaks', () => { + const lines = splitHighlightedLines('"a\nb"\n\nend'); + assert.deepEqual(lines, [ + '"a', + 'b"', + ' ', + 'end', + ]); +}); + +test('cachedRender reuses results for identical source and kind', () => { + let calls = 0; + const render = (text) => { calls += 1; return text.toUpperCase(); }; + const source = `cache-test-${Date.now()}`; + assert.equal(cachedRender('t', source, render), source.toUpperCase()); + assert.equal(cachedRender('t', source, render), source.toUpperCase()); + assert.equal(calls, 1); + cachedRender('other', source, render); + assert.equal(calls, 2); +}); diff --git a/client/src/components/SkillDetail.jsx b/client/src/components/SkillDetail.jsx index efebbef..2e7f472 100644 --- a/client/src/components/SkillDetail.jsx +++ b/client/src/components/SkillDetail.jsx @@ -1,24 +1,4 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { marked } from 'marked'; -import hljs from 'highlight.js/lib/core'; -import bash from 'highlight.js/lib/languages/bash'; -import c from 'highlight.js/lib/languages/c'; -import cpp from 'highlight.js/lib/languages/cpp'; -import css from 'highlight.js/lib/languages/css'; -import diff from 'highlight.js/lib/languages/diff'; -import go from 'highlight.js/lib/languages/go'; -import ini from 'highlight.js/lib/languages/ini'; -import java from 'highlight.js/lib/languages/java'; -import javascript from 'highlight.js/lib/languages/javascript'; -import json from 'highlight.js/lib/languages/json'; -import markdown from 'highlight.js/lib/languages/markdown'; -import python from 'highlight.js/lib/languages/python'; -import rust from 'highlight.js/lib/languages/rust'; -import scss from 'highlight.js/lib/languages/scss'; -import sql from 'highlight.js/lib/languages/sql'; -import typescript from 'highlight.js/lib/languages/typescript'; -import xml from 'highlight.js/lib/languages/xml'; -import yaml from 'highlight.js/lib/languages/yaml'; import { VersionHistoryModal } from './Modals'; import ReviewPanel from './ReviewPanel'; import { installCommand, skillPrompt } from '../utils/agentPrompts'; @@ -27,6 +7,7 @@ import useResizableWidth from '../hooks/useResizableWidth'; import { requestJson } from '../utils/requestJson'; import { formatDateTime, relativeTime } from '../utils/date'; import { extractHeadings, parseFrontmatterPairs, slugifyHeading, splitFrontmatter } from '../utils/markdownDoc'; +import { cachedRender, highlightCode, languageForPath, renderMarkdown, splitHighlightedLines } from '../utils/highlight'; import { Check, ChevronDown, @@ -46,12 +27,6 @@ import { PanelLeftClose, } from 'lucide-react'; -marked.setOptions({ breaks: true, gfm: true }); - -// 只注册文件查看器与代码块实际支持的语言。未知语言会安全地显示为纯文本。 -Object.entries({ bash, c, cpp, css, diff, go, ini, java, javascript, json, markdown, python, rust, scss, sql, typescript, xml, yaml }) - .forEach(([name, grammar]) => hljs.registerLanguage(name, grammar)); - // 悬浮大纲的视口阈值:与 index.css 的 .doc-outline 收窄断点保持一致 const OUTLINE_MIN_VIEWPORT = '(min-width: 1280px)'; @@ -141,121 +116,6 @@ function FileTreeNode({ node, depth, selectedFile, openFile, openDirs, toggleDir ); } -// 扩展名 -> highlight.js 语言(覆盖技能包常见脚本/配置类型) -const EXT_LANGUAGES = { - sh: 'bash', bash: 'bash', zsh: 'bash', - py: 'python', - js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', - ts: 'typescript', tsx: 'typescript', - json: 'json', - yaml: 'yaml', yml: 'yaml', - md: 'markdown', - html: 'xml', xml: 'xml', svg: 'xml', - css: 'css', scss: 'scss', - sql: 'sql', - ini: 'ini', toml: 'ini', env: 'ini', - diff: 'diff', patch: 'diff', - go: 'go', rs: 'rust', java: 'java', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', -}; - -function languageForPath(path) { - const ext = path.split('.').pop()?.toLowerCase() || ''; - return EXT_LANGUAGES[ext] || null; -} - -function escapeHtml(text) { - return text.replace(/[&<>]/g, (ch) => ({ '&': '&', '<': '<', '>': '>' }[ch])); -} - -function highlightCode(code, language) { - if (!code) return ''; - try { - if (language && hljs.getLanguage(language)) { - return hljs.highlight(code, { language, ignoreIllegals: true }).value; - } - } catch { /* 语法包异常时退回纯文本 */ } - // 未知语言不再走 highlightAuto:它要拿每种已注册语言各试跑一遍,代价是定向高亮的 8~20 倍 - return escapeHtml(code); -} - -// 把整段高亮 HTML 按换行切开,跨行的 在行尾闭合、行首重开—— -// 这样既保住了跨行语法(多行字符串 / 块注释),又能沿用 CSS counter 渲染行号。 -function splitHighlightedLines(html) { - const lines = []; - const open = []; - let buffer = ''; - let index = 0; - while (index < html.length) { - if (html[index] === '<') { - const end = html.indexOf('>', index); - if (end === -1) { buffer += html.slice(index); break; } - const tag = html.slice(index, end + 1); - if (tag.startsWith('')) open.push(tag); - buffer += tag; - index = end + 1; - continue; - } - const next = html.indexOf('<', index); - const text = next === -1 ? html.slice(index) : html.slice(index, next); - const parts = text.split('\n'); - parts.forEach((part, i) => { - if (i > 0) { - buffer += ''.repeat(open.length); - lines.push(buffer || ' '); - buffer = open.join(''); - } - buffer += part; - }); - index = next === -1 ? html.length : next; - } - lines.push(buffer || ' '); - return lines; -} - -// 轻量字符串哈希(djb2):给 marked / hljs 的结果做缓存键 -function hashSource(text) { - let hash = 5381; - for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; - return `${hash.toString(36)}:${text.length}`; -} - -// 渲染结果按内容哈希缓存(小 LRU):来回切技能、预览↔编辑来回切都不必重跑 marked + hljs -const RENDER_CACHE = new Map(); -const RENDER_CACHE_MAX = 24; -function cachedRender(kind, source, render) { - const key = `${kind}:${hashSource(source)}`; - if (RENDER_CACHE.has(key)) { - const hit = RENDER_CACHE.get(key); - RENDER_CACHE.delete(key); - RENDER_CACHE.set(key, hit); // 命中后移到队尾 - return hit; - } - const value = render(source); - RENDER_CACHE.set(key, value); - if (RENDER_CACHE.size > RENDER_CACHE_MAX) RENDER_CACHE.delete(RENDER_CACHE.keys().next().value); - return value; -} - -// 代码块高亮 + 语言标签:对 marked 输出的 HTML 做后处理,比覆写 renderer 稳定(marked v18) -function highlightMarkdownHtml(html) { - if (!html) return ''; - const container = document.createElement('div'); - container.innerHTML = html; - container.querySelectorAll('pre > code').forEach((block) => { - const classMatch = block.className.match(/language-([\w-]+)/); - const lang = classMatch ? classMatch[1].toLowerCase() : null; - block.innerHTML = highlightCode(block.textContent || '', lang); - const pre = block.parentElement; - if (pre) { - pre.setAttribute('data-lang', lang || 'text'); - } - }); - return container.innerHTML; -} - -const renderMarkdown = (source) => highlightMarkdownHtml(marked.parse(source)); - function readSkillDraft(skillId) { try { const draft = JSON.parse(window.localStorage.getItem(`ash:skill-draft:${skillId}`)); diff --git a/client/src/utils/highlight.js b/client/src/utils/highlight.js new file mode 100644 index 0000000..1b476d3 --- /dev/null +++ b/client/src/utils/highlight.js @@ -0,0 +1,143 @@ +// 详情页的 markdown 渲染与代码高亮。marked / highlight.js 只在这里配置一次, +// 也只应被详情页(懒加载 chunk)引用——从首屏代码 import 会把 hljs 拖进主包。 +import { marked } from 'marked'; +import hljs from 'highlight.js/lib/core'; +import bash from 'highlight.js/lib/languages/bash'; +import c from 'highlight.js/lib/languages/c'; +import cpp from 'highlight.js/lib/languages/cpp'; +import css from 'highlight.js/lib/languages/css'; +import diff from 'highlight.js/lib/languages/diff'; +import go from 'highlight.js/lib/languages/go'; +import ini from 'highlight.js/lib/languages/ini'; +import java from 'highlight.js/lib/languages/java'; +import javascript from 'highlight.js/lib/languages/javascript'; +import json from 'highlight.js/lib/languages/json'; +import markdown from 'highlight.js/lib/languages/markdown'; +import python from 'highlight.js/lib/languages/python'; +import rust from 'highlight.js/lib/languages/rust'; +import scss from 'highlight.js/lib/languages/scss'; +import sql from 'highlight.js/lib/languages/sql'; +import typescript from 'highlight.js/lib/languages/typescript'; +import xml from 'highlight.js/lib/languages/xml'; +import yaml from 'highlight.js/lib/languages/yaml'; + +marked.setOptions({ breaks: true, gfm: true }); + +// 只注册文件查看器与代码块实际支持的语言。未知语言会安全地显示为纯文本。 +Object.entries({ bash, c, cpp, css, diff, go, ini, java, javascript, json, markdown, python, rust, scss, sql, typescript, xml, yaml }) + .forEach(([name, grammar]) => hljs.registerLanguage(name, grammar)); + +// 扩展名 -> highlight.js 语言(覆盖技能包常见脚本/配置类型) +const EXT_LANGUAGES = { + sh: 'bash', bash: 'bash', zsh: 'bash', + py: 'python', + js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', + ts: 'typescript', tsx: 'typescript', + json: 'json', + yaml: 'yaml', yml: 'yaml', + md: 'markdown', + html: 'xml', xml: 'xml', svg: 'xml', + css: 'css', scss: 'scss', + sql: 'sql', + ini: 'ini', toml: 'ini', env: 'ini', + diff: 'diff', patch: 'diff', + go: 'go', rs: 'rust', java: 'java', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', +}; + +export function languageForPath(path) { + const ext = path.split('.').pop()?.toLowerCase() || ''; + return EXT_LANGUAGES[ext] || null; +} + +function escapeHtml(text) { + return text.replace(/[&<>]/g, (ch) => ({ '&': '&', '<': '<', '>': '>' }[ch])); +} + +export function highlightCode(code, language) { + if (!code) return ''; + try { + if (language && hljs.getLanguage(language)) { + return hljs.highlight(code, { language, ignoreIllegals: true }).value; + } + } catch { /* 语法包异常时退回纯文本 */ } + // 未知语言不再走 highlightAuto:它要拿每种已注册语言各试跑一遍,代价是定向高亮的 8~20 倍 + return escapeHtml(code); +} + +// 把整段高亮 HTML 按换行切开,跨行的 在行尾闭合、行首重开—— +// 这样既保住了跨行语法(多行字符串 / 块注释),又能沿用 CSS counter 渲染行号。 +export function splitHighlightedLines(html) { + const lines = []; + const open = []; + let buffer = ''; + let index = 0; + while (index < html.length) { + if (html[index] === '<') { + const end = html.indexOf('>', index); + if (end === -1) { buffer += html.slice(index); break; } + const tag = html.slice(index, end + 1); + if (tag.startsWith('')) open.push(tag); + buffer += tag; + index = end + 1; + continue; + } + const next = html.indexOf('<', index); + const text = next === -1 ? html.slice(index) : html.slice(index, next); + const parts = text.split('\n'); + parts.forEach((part, i) => { + if (i > 0) { + buffer += ''.repeat(open.length); + lines.push(buffer || ' '); + buffer = open.join(''); + } + buffer += part; + }); + index = next === -1 ? html.length : next; + } + lines.push(buffer || ' '); + return lines; +} + +// 轻量字符串哈希(djb2):给 marked / hljs 的结果做缓存键 +function hashSource(text) { + let hash = 5381; + for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; + return `${hash.toString(36)}:${text.length}`; +} + +// 渲染结果按内容哈希缓存(小 LRU):来回切技能、预览↔编辑来回切都不必重跑 marked + hljs +const RENDER_CACHE = new Map(); +const RENDER_CACHE_MAX = 24; +export function cachedRender(kind, source, render) { + const key = `${kind}:${hashSource(source)}`; + if (RENDER_CACHE.has(key)) { + const hit = RENDER_CACHE.get(key); + RENDER_CACHE.delete(key); + RENDER_CACHE.set(key, hit); // 命中后移到队尾 + return hit; + } + const value = render(source); + RENDER_CACHE.set(key, value); + if (RENDER_CACHE.size > RENDER_CACHE_MAX) RENDER_CACHE.delete(RENDER_CACHE.keys().next().value); + return value; +} + +// 代码块高亮 + 语言标签:对 marked 输出的 HTML 做后处理,比覆写 renderer 稳定(marked v18) +function highlightMarkdownHtml(html) { + if (!html) return ''; + const container = document.createElement('div'); + container.innerHTML = html; + container.querySelectorAll('pre > code').forEach((block) => { + const classMatch = block.className.match(/language-([\w-]+)/); + const lang = classMatch ? classMatch[1].toLowerCase() : null; + block.innerHTML = highlightCode(block.textContent || '', lang); + const pre = block.parentElement; + if (pre) { + pre.setAttribute('data-lang', lang || 'text'); + } + }); + return container.innerHTML; +} + +export const renderMarkdown = (source) => highlightMarkdownHtml(marked.parse(source)); From 117b2b860c3145d86cf241eb418a73dbf682f27e Mon Sep 17 00:00:00 2001 From: john Date: Mon, 28 Sep 2026 13:45:21 +0000 Subject: [PATCH 3/5] refactor(detail): extract FileTreeSidebar component buildFileTree, FileTreeNode, the path filter, open-dir state and the resizable width move into components/detail/FileTreeSidebar.jsx. The auto-collapse ResizeObserver stays in SkillDetail (it watches the detail scroll area) and drives the sidebar through collapsed / onCollapse / onExpand. SkillDetail is keyed by skill id, so the moved state still resets per skill exactly as before. Co-Authored-By: Claude Opus 5.5 --- client/src/components/SkillDetail.jsx | 180 ++---------------- .../src/components/detail/FileTreeSidebar.jsx | 176 +++++++++++++++++ 2 files changed, 187 insertions(+), 169 deletions(-) create mode 100644 client/src/components/detail/FileTreeSidebar.jsx diff --git a/client/src/components/SkillDetail.jsx b/client/src/components/SkillDetail.jsx index 2e7f472..4eb1045 100644 --- a/client/src/components/SkillDetail.jsx +++ b/client/src/components/SkillDetail.jsx @@ -1,16 +1,15 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { VersionHistoryModal } from './Modals'; import ReviewPanel from './ReviewPanel'; +import FileTreeSidebar from './detail/FileTreeSidebar'; import { installCommand, skillPrompt } from '../utils/agentPrompts'; import { showToast } from './toastBus'; -import useResizableWidth from '../hooks/useResizableWidth'; import { requestJson } from '../utils/requestJson'; import { formatDateTime, relativeTime } from '../utils/date'; import { extractHeadings, parseFrontmatterPairs, slugifyHeading, splitFrontmatter } from '../utils/markdownDoc'; import { cachedRender, highlightCode, languageForPath, renderMarkdown, splitHighlightedLines } from '../utils/highlight'; import { Check, - ChevronDown, ChevronRight, Copy, Download, @@ -18,13 +17,10 @@ import { Eye, EyeOff, FileCode2, - FileText, - Folder, List, Terminal, History, MoreHorizontal, - PanelLeftClose, } from 'lucide-react'; // 悬浮大纲的视口阈值:与 index.css 的 .doc-outline 收窄断点保持一致 @@ -32,90 +28,6 @@ const OUTLINE_MIN_VIEWPORT = '(min-width: 1280px)'; const HEADING_SELECTOR = '.markdown-document h1, .markdown-document h2, .markdown-document h3, .markdown-document h4'; -function buildFileTree(files) { - const root = { name: '', path: '', children: new Map(), file: null }; - for (const file of files) { - const parts = file.path.split('/'); - let node = root; - parts.forEach((part, depth) => { - const isLeaf = depth === parts.length - 1; - if (!node.children.has(part)) { - node.children.set(part, { name: part, path: parts.slice(0, depth + 1).join('/'), children: new Map(), file: null }); - } - node = node.children.get(part); - if (isLeaf) node.file = file; - }); - } - return root; -} - -function FileTreeNode({ node, depth, selectedFile, openFile, openDirs, toggleDir, hasSelectionInside, filterActive }) { - const dirEntries = [...node.children.values()].sort((a, b) => { - const aDir = a.children.size > 0; - const bDir = b.children.size > 0; - if (aDir !== bDir) return aDir ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - return ( -
    - {dirEntries.map((entry) => { - const isDir = entry.children.size > 0; - // 过滤激活时强制展开所有目录,保证匹配文件可见 - const expanded = filterActive || openDirs.has(entry.path); - if (isDir) { - return ( -
  • - - {(expanded || hasSelectionInside(entry)) && ( - - )} -
  • - ); - } - const file = entry.file; - const active = selectedFile === file.path; - return ( -
  • - -
  • - ); - })} -
- ); -} - function readSkillDraft(skillId) { try { const draft = JSON.parse(window.localStorage.getItem(`ash:skill-draft:${skillId}`)); @@ -147,9 +59,7 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag const [detailError, setDetailError] = useState(''); const [fileError, setFileError] = useState(''); const [copied, setCopied] = useState(''); - const [openDirs, setOpenDirs] = useState(() => new Set()); - // D1: 面板宽度/大纲显隐持久化 - const [fileSidebarWidth, , fileSidebarResizer] = useResizableWidth('file-sidebar-width', { min: 160, max: 480, initial: 240, label: '调整技能文件栏宽度' }); + // D1: 大纲显隐持久化 const [showVersions, setShowVersions] = useState(false); const [moreOpen, setMoreOpen] = useState(false); const [outlineHidden, setOutlineHidden] = useState(() => { @@ -162,8 +72,6 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag // 文件栏在滚动区 <640px 时自动收成细条(点击恢复),正文优先 const [fileBarCollapsed, setFileBarCollapsed] = useState(false); const fileBarManualRef = useRef(false); // 用户手动展开过则不再自动收起 - // A2: 文件树过滤 - const [fileFilter, setFileFilter] = useState(''); // B1: 大纲当前高亮索引 const [activeHeading, setActiveHeading] = useState(0); const scrollRef = useRef(null); @@ -383,37 +291,9 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag }; }, [headings.length, renderedMarkdown, auxRendered, selectedFile, mode]); - const treeData = useMemo(() => buildFileTree(fileTree), [fileTree]); - - // A2: 文件树过滤(含路径子串匹配) - const filteredTreeData = useMemo(() => { - const q = fileFilter.trim().toLowerCase(); - if (!q) return treeData; - const filtered = fileTree.filter((f) => f.path.toLowerCase().includes(q)); - return buildFileTree(filtered); - }, [treeData, fileFilter, fileTree]); - // B5: frontmatter 键值对 const frontmatterPairs = useMemo(() => parseFrontmatterPairs(documentParts.frontmatter), [documentParts.frontmatter]); - const toggleDir = (dirPath) => { - setOpenDirs((prev) => { - const next = new Set(prev); - if (next.has(dirPath)) next.delete(dirPath); - else next.add(dirPath); - return next; - }); - }; - - const hasSelectionInside = (node) => { - if (!selectedFile) return false; - if (node.file && node.file.path === selectedFile) return true; - for (const child of node.children.values()) { - if (hasSelectionInside(child)) return true; - } - return false; - }; - const jumpToHeading = (index) => { const container = scrollRef.current; if (!container) return; @@ -606,53 +486,15 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag }} />
- {fileTree.length > 1 && fileBarCollapsed && ( - - )} - {fileTree.length > 1 && !fileBarCollapsed && ( - + {fileTree.length > 1 && ( + { fileBarManualRef.current = false; setFileBarCollapsed(true); }} + onExpand={() => { fileBarManualRef.current = true; setFileBarCollapsed(false); }} + /> )}
1 ? ' has-outline' : ''}`} ref={scrollRef}> diff --git a/client/src/components/detail/FileTreeSidebar.jsx b/client/src/components/detail/FileTreeSidebar.jsx new file mode 100644 index 0000000..a89c25d --- /dev/null +++ b/client/src/components/detail/FileTreeSidebar.jsx @@ -0,0 +1,176 @@ +import { useMemo, useState } from 'react'; +import { ChevronDown, ChevronRight, FileCode2, FileText, Folder, PanelLeftClose } from 'lucide-react'; +import useResizableWidth from '../../hooks/useResizableWidth'; + +function buildFileTree(files) { + const root = { name: '', path: '', children: new Map(), file: null }; + for (const file of files) { + const parts = file.path.split('/'); + let node = root; + parts.forEach((part, depth) => { + const isLeaf = depth === parts.length - 1; + if (!node.children.has(part)) { + node.children.set(part, { name: part, path: parts.slice(0, depth + 1).join('/'), children: new Map(), file: null }); + } + node = node.children.get(part); + if (isLeaf) node.file = file; + }); + } + return root; +} + +function FileTreeNode({ node, depth, selectedFile, openFile, openDirs, toggleDir, hasSelectionInside, filterActive }) { + const dirEntries = [...node.children.values()].sort((a, b) => { + const aDir = a.children.size > 0; + const bDir = b.children.size > 0; + if (aDir !== bDir) return aDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return ( +
    + {dirEntries.map((entry) => { + const isDir = entry.children.size > 0; + // 过滤激活时强制展开所有目录,保证匹配文件可见 + const expanded = filterActive || openDirs.has(entry.path); + if (isDir) { + return ( +
  • + + {(expanded || hasSelectionInside(entry)) && ( + + )} +
  • + ); + } + const file = entry.file; + const active = selectedFile === file.path; + return ( +
  • + +
  • + ); + })} +
+ ); +} + +// 技能文件栏:文件树 + 路径过滤 + 可拖宽。收起/展开由 SkillDetail 按滚动区宽度决定,这里只渲染两种形态。 +export default function FileTreeSidebar({ files, selectedFile, onOpenFile, collapsed, onCollapse, onExpand }) { + const [openDirs, setOpenDirs] = useState(() => new Set()); + // D1: 面板宽度持久化 + const [fileSidebarWidth, , fileSidebarResizer] = useResizableWidth('file-sidebar-width', { min: 160, max: 480, initial: 240, label: '调整技能文件栏宽度' }); + // A2: 文件树过滤 + const [fileFilter, setFileFilter] = useState(''); + + const treeData = useMemo(() => buildFileTree(files), [files]); + + // A2: 文件树过滤(含路径子串匹配) + const filteredTreeData = useMemo(() => { + const q = fileFilter.trim().toLowerCase(); + if (!q) return treeData; + const filtered = files.filter((f) => f.path.toLowerCase().includes(q)); + return buildFileTree(filtered); + }, [treeData, fileFilter, files]); + + const toggleDir = (dirPath) => { + setOpenDirs((prev) => { + const next = new Set(prev); + if (next.has(dirPath)) next.delete(dirPath); + else next.add(dirPath); + return next; + }); + }; + + const hasSelectionInside = (node) => { + if (!selectedFile) return false; + if (node.file && node.file.path === selectedFile) return true; + for (const child of node.children.values()) { + if (hasSelectionInside(child)) return true; + } + return false; + }; + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} From 972875ea998a882a0fab5d3340aac0ffa1d427fe Mon Sep 17 00:00:00 2001 From: john Date: Mon, 28 Sep 2026 13:46:07 +0000 Subject: [PATCH 4/5] refactor(detail): extract DetailToolbar component Breadcrumb, meta chips, copy/download/history buttons, the overflow menu (with its outside-click / Escape handling) and the preview/edit switch move to components/detail/DetailToolbar.jsx. Copy feedback, the version modal and save/mode logic stay in SkillDetail and are passed in. The breadcrumb regression test now reads the toolbar file; its assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- client/scripts/ui-regression.test.mjs | 5 +- client/src/components/SkillDetail.jsx | 129 +++--------------- .../src/components/detail/DetailToolbar.jsx | 129 ++++++++++++++++++ 3 files changed, 148 insertions(+), 115 deletions(-) create mode 100644 client/src/components/detail/DetailToolbar.jsx diff --git a/client/scripts/ui-regression.test.mjs b/client/scripts/ui-regression.test.mjs index 70665d5..a2c3b62 100644 --- a/client/scripts/ui-regression.test.mjs +++ b/client/scripts/ui-regression.test.mjs @@ -21,8 +21,9 @@ test('App wires every interactive component callback to an implementation', asyn test('detail breadcrumb navigates to the folder instead of moving the skill into it', async () => { const detail = await source('src/components/SkillDetail.jsx'); - assert.ok(!detail.includes('onMoveFolder'), 'breadcrumb must not trigger a move mutation'); - assert.match(detail, /crumb-link[\s\S]{0,80}?onSelectFolder\?\.\(arr\.slice\(0, i \+ 1\)\.join\('\/'\)\)/, 'each crumb segment navigates to its own path prefix'); + const toolbar = await source('src/components/detail/DetailToolbar.jsx'); + assert.ok(!detail.includes('onMoveFolder') && !toolbar.includes('onMoveFolder'), 'breadcrumb must not trigger a move mutation'); + assert.match(toolbar, /crumb-link[\s\S]{0,80}?onSelectFolder\?\.\(arr\.slice\(0, i \+ 1\)\.join\('\/'\)\)/, 'each crumb segment navigates to its own path prefix'); }); test('detail always fetches the full record so file_tree stays available', async () => { diff --git a/client/src/components/SkillDetail.jsx b/client/src/components/SkillDetail.jsx index 4eb1045..dce73f9 100644 --- a/client/src/components/SkillDetail.jsx +++ b/client/src/components/SkillDetail.jsx @@ -1,26 +1,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { VersionHistoryModal } from './Modals'; import ReviewPanel from './ReviewPanel'; +import DetailToolbar from './detail/DetailToolbar'; import FileTreeSidebar from './detail/FileTreeSidebar'; -import { installCommand, skillPrompt } from '../utils/agentPrompts'; import { showToast } from './toastBus'; import { requestJson } from '../utils/requestJson'; -import { formatDateTime, relativeTime } from '../utils/date'; import { extractHeadings, parseFrontmatterPairs, slugifyHeading, splitFrontmatter } from '../utils/markdownDoc'; import { cachedRender, highlightCode, languageForPath, renderMarkdown, splitHighlightedLines } from '../utils/highlight'; import { - Check, - ChevronRight, - Copy, - Download, - Edit3, - Eye, EyeOff, FileCode2, List, - Terminal, - History, - MoreHorizontal, } from 'lucide-react'; // 悬浮大纲的视口阈值:与 index.css 的 .doc-outline 收窄断点保持一致 @@ -61,7 +51,6 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag const [copied, setCopied] = useState(''); // D1: 大纲显隐持久化 const [showVersions, setShowVersions] = useState(false); - const [moreOpen, setMoreOpen] = useState(false); const [outlineHidden, setOutlineHidden] = useState(() => { try { return window.localStorage.getItem('ash:outline-hidden') === '1'; } catch { return false; } @@ -99,19 +88,6 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag catch { /* 存储不可用时仍可在当前会话切换大纲 */ } }, [outlineHidden]); - // 溢出菜单:点外部或按 Esc 关闭 - useEffect(() => { - if (!moreOpen) return undefined; - const onPointerDown = (event) => { if (!event.target.closest('.detail-more')) setMoreOpen(false); }; - const onKeyDown = (event) => { if (event.key === 'Escape') setMoreOpen(false); }; - document.addEventListener('mousedown', onPointerDown); - document.addEventListener('keydown', onKeyDown); - return () => { - document.removeEventListener('mousedown', onPointerDown); - document.removeEventListener('keydown', onKeyDown); - }; - }, [moreOpen]); - useEffect(() => { let cancelled = false; requestJson(`/api/skills/${skill.id}`) @@ -319,10 +295,6 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag container.scrollTo({ top: container.scrollTop + delta - 12, behavior: 'smooth' }); }; - const origin = window.location.origin; - const agentPrompt = skillPrompt(origin, { ...skill, file_count: fileTree.length || skill.file_count }); - const cliCommand = installCommand(origin, skill.slug); - const copyToClipboard = async (text, type, toastLabel) => { try { await navigator.clipboard.writeText(text); @@ -387,18 +359,6 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag } }, [skill.id]); - // 附属文件单独下载:/api/skills/:id/file 返回 JSON 包装,直接做成本地 Blob 更省事 - const downloadCurrentFile = () => { - const url = URL.createObjectURL(new Blob([auxFileContent], { type: 'text/plain;charset=utf-8' })); - const link = document.createElement('a'); - link.href = url; - link.download = selectedFile.split('/').pop(); - document.body.appendChild(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 0); - }; - const openFile = (path) => { setFileError(''); setAuxFileContent(''); @@ -408,78 +368,21 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag return (
-
-
- -

{skill.name}

-
- - {/* 头部中段原来是一大片空白:放只读身份信息(版本 / 更新时间 / 文件数 / 来源终端) */} -
- {skill.version && v{skill.version}} - {skill.updated_at && {relativeTime(skill.updated_at)}} - {fileTree.length > 1 && {fileTree.length} 个文件} - {skill.terminal_source && {skill.terminal_source}} -
- -
- - - - - - - {/* 溢出菜单:一键取值(slug / 路径 / 链接)与「复制技能」——后者后端早有 API,前端一直没入口 */} -
- - {moreOpen && ( -
- - - - - {selectedFile !== 'SKILL.md' && ( - <> - - - - )} -
- )} -
-
- - -
-
-
+ setShowVersions(true)} + mode={mode} + dirty={dirty} + saving={saving} + onSwitchMode={switchMode} + onSelectFolder={onSelectFolder} + onCopySkill={onCopySkill} + /> setShowVersions(false)} skillId={skill.id} onRestored={async () => { const fresh = await requestJson(`/api/skills/${skill.id}`).catch(() => null); if (fresh) { setContent(fresh.content || ''); setSavedContent(fresh.content || ''); } diff --git a/client/src/components/detail/DetailToolbar.jsx b/client/src/components/detail/DetailToolbar.jsx new file mode 100644 index 0000000..d9ce48c --- /dev/null +++ b/client/src/components/detail/DetailToolbar.jsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react'; +import { Check, ChevronRight, Copy, Download, Edit3, Eye, History, MoreHorizontal, Terminal } from 'lucide-react'; +import { showToast } from '../toastBus'; +import { installCommand, skillPrompt } from '../../utils/agentPrompts'; +import { formatDateTime, relativeTime } from '../../utils/date'; + +// 详情头部:面包屑 + 身份信息 + 复制/下载/历史/更多 + 预览/编辑切换。 +// 复制反馈(copied)、版本弹窗和保存逻辑仍归 SkillDetail,这里只负责展示与触发。 +export default function DetailToolbar({ + skill, + fileCount, + selectedFile, + auxFileContent, + copied, + onCopy, + onShowVersions, + mode, + dirty, + saving, + onSwitchMode, + onSelectFolder, + onCopySkill, +}) { + const [moreOpen, setMoreOpen] = useState(false); + + // 溢出菜单:点外部或按 Esc 关闭 + useEffect(() => { + if (!moreOpen) return undefined; + const onPointerDown = (event) => { if (!event.target.closest('.detail-more')) setMoreOpen(false); }; + const onKeyDown = (event) => { if (event.key === 'Escape') setMoreOpen(false); }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [moreOpen]); + + const origin = window.location.origin; + const agentPrompt = skillPrompt(origin, { ...skill, file_count: fileCount || skill.file_count }); + const cliCommand = installCommand(origin, skill.slug); + + // 附属文件单独下载:/api/skills/:id/file 返回 JSON 包装,直接做成本地 Blob 更省事 + const downloadCurrentFile = () => { + const url = URL.createObjectURL(new Blob([auxFileContent], { type: 'text/plain;charset=utf-8' })); + const link = document.createElement('a'); + link.href = url; + link.download = selectedFile.split('/').pop(); + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + }; + + return ( +
+
+ +

{skill.name}

+
+ + {/* 头部中段原来是一大片空白:放只读身份信息(版本 / 更新时间 / 文件数 / 来源终端) */} +
+ {skill.version && v{skill.version}} + {skill.updated_at && {relativeTime(skill.updated_at)}} + {fileCount > 1 && {fileCount} 个文件} + {skill.terminal_source && {skill.terminal_source}} +
+ +
+ + + + + + + {/* 溢出菜单:一键取值(slug / 路径 / 链接)与「复制技能」——后者后端早有 API,前端一直没入口 */} +
+ + {moreOpen && ( +
+ + + + + {selectedFile !== 'SKILL.md' && ( + <> + + + + )} +
+ )} +
+
+ + +
+
+
+ ); +} From 38ceda61e3030e27af01db81c1e7018776e7fc03 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 28 Sep 2026 13:47:16 +0000 Subject: [PATCH 5/5] refactor(detail): extract useDocOutline hook and DocOutline component Outline visibility preference, the viewport-width gate, scroll-spy and jumpToHeading move to hooks/useDocOutline.js; the outline nav / restore button JSX moves to components/detail/DocOutline.jsx. The four-part visibility condition that was repeated three times is now one 'visible' flag. The hook is called right after the heading-id DOM patch effect so effect order is unchanged. Co-Authored-By: Claude Opus 5.5 --- client/src/components/SkillDetail.jsx | 157 ++------------------ client/src/components/detail/DocOutline.jsx | 44 ++++++ client/src/hooks/useDocOutline.js | 117 +++++++++++++++ 3 files changed, 175 insertions(+), 143 deletions(-) create mode 100644 client/src/components/detail/DocOutline.jsx create mode 100644 client/src/hooks/useDocOutline.js diff --git a/client/src/components/SkillDetail.jsx b/client/src/components/SkillDetail.jsx index dce73f9..d62a41b 100644 --- a/client/src/components/SkillDetail.jsx +++ b/client/src/components/SkillDetail.jsx @@ -2,21 +2,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { VersionHistoryModal } from './Modals'; import ReviewPanel from './ReviewPanel'; import DetailToolbar from './detail/DetailToolbar'; +import DocOutline from './detail/DocOutline'; import FileTreeSidebar from './detail/FileTreeSidebar'; import { showToast } from './toastBus'; import { requestJson } from '../utils/requestJson'; import { extractHeadings, parseFrontmatterPairs, slugifyHeading, splitFrontmatter } from '../utils/markdownDoc'; +import useDocOutline, { HEADING_SELECTOR } from '../hooks/useDocOutline'; import { cachedRender, highlightCode, languageForPath, renderMarkdown, splitHighlightedLines } from '../utils/highlight'; -import { - EyeOff, - FileCode2, - List, -} from 'lucide-react'; - -// 悬浮大纲的视口阈值:与 index.css 的 .doc-outline 收窄断点保持一致 -const OUTLINE_MIN_VIEWPORT = '(min-width: 1280px)'; - -const HEADING_SELECTOR = '.markdown-document h1, .markdown-document h2, .markdown-document h3, .markdown-document h4'; +import { FileCode2 } from 'lucide-react'; function readSkillDraft(skillId) { try { @@ -49,30 +42,12 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag const [detailError, setDetailError] = useState(''); const [fileError, setFileError] = useState(''); const [copied, setCopied] = useState(''); - // D1: 大纲显隐持久化 const [showVersions, setShowVersions] = useState(false); - const [outlineHidden, setOutlineHidden] = useState(() => { - try { return window.localStorage.getItem('ash:outline-hidden') === '1'; } - catch { return false; } - }); - // 大纲按「视口宽度」判定,而不是滚动区宽度:滚动区永远是视口减去左两栏, - // 用它做阈值会让大纲在 1684px 以下的视口里永远不出现。 - const [outlineFits, setOutlineFits] = useState(() => window.matchMedia(OUTLINE_MIN_VIEWPORT).matches); // 文件栏在滚动区 <640px 时自动收成细条(点击恢复),正文优先 const [fileBarCollapsed, setFileBarCollapsed] = useState(false); const fileBarManualRef = useRef(false); // 用户手动展开过则不再自动收起 - // B1: 大纲当前高亮索引 - const [activeHeading, setActiveHeading] = useState(0); const scrollRef = useRef(null); - useEffect(() => { - const query = window.matchMedia(OUTLINE_MIN_VIEWPORT); - const sync = () => setOutlineFits(query.matches); - sync(); - query.addEventListener('change', sync); - return () => query.removeEventListener('change', sync); - }, []); - useEffect(() => { const el = scrollRef.current; if (!el || typeof ResizeObserver === 'undefined') return undefined; @@ -83,11 +58,6 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag return () => observer.disconnect(); }, []); - useEffect(() => { - try { window.localStorage.setItem('ash:outline-hidden', outlineHidden ? '1' : '0'); } - catch { /* 存储不可用时仍可在当前会话切换大纲 */ } - }, [outlineHidden]); - useEffect(() => { let cancelled = false; requestJson(`/api/skills/${skill.id}`) @@ -217,84 +187,11 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag return undefined; }, [renderedMarkdown, auxRendered, selectedFile, mode]); - // B1: 大纲跟随滚动高亮(scroll-spy) - // 原实现每帧 querySelectorAll + 逐标题 getBoundingClientRect——每次滚动都强制同步布局。 - // 改成:标题位置只在内容/尺寸变化时量一次,滚动中只比 scrollTop,零 rect 读取。 - const headingOffsetsRef = useRef([]); - useEffect(() => { - const container = scrollRef.current; - if (!container || headings.length < 2) return undefined; - - const measure = () => { - const containerTop = container.getBoundingClientRect().top; - headingOffsetsRef.current = [...container.querySelectorAll(HEADING_SELECTOR)] - .map((node) => node.getBoundingClientRect().top - containerTop + container.scrollTop); - }; - - let ticking = false; - const onScroll = () => { - if (ticking) return; - ticking = true; - window.requestAnimationFrame(() => { - ticking = false; - const offsets = headingOffsetsRef.current; - if (!offsets.length) return; - const line = container.scrollTop + 60; - let current = 0; - for (let i = 0; i < offsets.length; i += 1) { - if (offsets[i] <= line) current = i; - } - // 滚动到底时高亮最后一个标题(末尾内容不足一屏时永远差一点) - if (container.scrollTop + container.clientHeight >= container.scrollHeight - 4) { - current = offsets.length - 1; - } - setActiveHeading(current); - }); - }; - - measure(); - onScroll(); - container.addEventListener('scroll', onScroll, { passive: true }); - // 字体/图片加载或面板改宽都会挪动标题:重量一次,仍然不碰滚动路径 - const content = container.querySelector('.detail-content'); - const observer = content && typeof ResizeObserver !== 'undefined' - ? new ResizeObserver(() => { measure(); onScroll(); }) - : null; - if (observer && content) observer.observe(content); - return () => { - container.removeEventListener('scroll', onScroll); - observer?.disconnect(); - }; - }, [headings.length, renderedMarkdown, auxRendered, selectedFile, mode]); + const outline = useDocOutline({ scrollRef, headings, mode, renderedMarkdown, auxRendered, selectedFile }); // B5: frontmatter 键值对 const frontmatterPairs = useMemo(() => parseFrontmatterPairs(documentParts.frontmatter), [documentParts.frontmatter]); - const jumpToHeading = (index) => { - const container = scrollRef.current; - if (!container) return; - const nodes = container.querySelectorAll(HEADING_SELECTOR); - const target = nodes[index]; - if (!target) return; - // offsetTop 的参照系是 offsetParent(.app-detail),不是滚动容器; - // 用 rect 相对差值计算真实滚动位置 - const delta = target.getBoundingClientRect().top - container.getBoundingClientRect().top; - // 末尾标题补位:确保即使内容不足也能把标题滚到视口上部 - const needed = container.scrollTop + delta - 12; - const maxScroll = container.scrollHeight - container.clientHeight; - if (needed > maxScroll) { - // 动态撑高底部 padding,让最后标题可达 - const content = container.querySelector('.detail-content'); - if (content) { - const extra = needed - maxScroll; - content.style.paddingBottom = `${Math.round(extra + 3 * 16)}px`; - window.requestAnimationFrame(() => container.scrollTo({ top: needed, behavior: 'smooth' })); - return; - } - } - container.scrollTo({ top: container.scrollTop + delta - 12, behavior: 'smooth' }); - }; - const copyToClipboard = async (text, type, toastLabel) => { try { await navigator.clipboard.writeText(text); @@ -400,42 +297,16 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag /> )} -
1 ? ' has-outline' : ''}`} ref={scrollRef}> - {mode === 'preview' && !outlineHidden && outlineFits && headings.length > 1 && ( - - )} - {mode === 'preview' && outlineHidden && outlineFits && headings.length > 1 && ( - +
+ {outline.visible && ( +