feat: migrate to react-markdown for robust markdown rendering

- Replace custom markdown parser with react-markdown + remark-gfm
- Fix document link navigation (./ and ../ references)
- Simplify doc viewing flow (direct markdown content instead of parsed structure)
- Update electron main process to only use api folder for docs
- Add blueprint loading from docs/blueprint.md dynamically
- Fix sidebar file selection path matching
- Update preload scripts for new API structure
This commit is contained in:
2026-03-18 18:50:26 +08:00
parent 4898ee5434
commit d66f5b09e6
16 changed files with 2003 additions and 451 deletions

View File

@@ -1,65 +1,129 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useCallback } from 'react'
import { X, Plus, AlertCircle } from 'lucide-react'
import { DocTree } from './DocTree'
import { DocContent } from './DocContent'
import { parseMarkdown, buildFileTree } from '@/lib/parser'
import type { DocFile, ParsedDoc } from '@/lib/types'
import { config } from '@/config'
import { buildFileTree } from '@/lib/parser'
import type { DocFile } from '@/lib/types'
const modules = import.meta.glob('../docs/api/**/*.md', { as: 'raw', eager: true })
interface ExternalDoc {
name: string
path: string
relativePath: string
content: string
}
const DOCS_FILES: Record<string, string> = Object.fromEntries(
Object.entries(modules).map(([path, content]) => {
const relativePath = path.replace('../docs/api/', '')
const fileContent = typeof content === 'string' ? content : ''
return [relativePath, fileContent]
})
)
interface ApiDocViewerProps {
onDocsPathChange?: (path: string) => void;
}
export const ApiDocViewer = () => {
export const ApiDocViewer = ({ onDocsPathChange }: ApiDocViewerProps) => {
const [fileTree, setFileTree] = useState<DocFile[]>([])
const [selectedPath, setSelectedPath] = useState<string | undefined>()
const [currentDoc, setCurrentDoc] = useState<ParsedDoc | null>(null)
const [currentContent, setCurrentContent] = useState<string>('')
const [showModal, setShowModal] = useState(false)
const [docsPath, setDocsPath] = useState('')
const [externalDocs, setExternalDocs] = useState<ExternalDoc[]>([])
const [isLoading, setIsLoading] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
useEffect(() => {
const files = Object.keys(DOCS_FILES)
const tree = buildFileTree(files, '/')
setFileTree(tree)
const loadDocsFromPath = async (basePath: string): Promise<boolean> => {
if (!basePath) {
setErrorMsg('请输入文档路径')
return false
}
if (files.length > 0) {
setSelectedPath(files[0])
if (!window.electronAPI) {
setErrorMsg('Electron API 不可用,请使用打包后的应用')
return false
}
}, [])
useEffect(() => {
if (selectedPath) {
const content = DOCS_FILES[selectedPath]
if (content) {
const parsed = parseMarkdown(content)
setCurrentDoc(parsed)
setIsLoading(true)
setErrorMsg(null)
try {
const files = await window.electronAPI.listDocsFiles(basePath)
if (files.length === 0) {
setErrorMsg(`路径 "${basePath}/api" 下没有找到 .md 文件`)
setExternalDocs([])
setFileTree([])
setSelectedPath(undefined)
setCurrentContent('')
return false
}
const docs: ExternalDoc[] = []
for (const file of files) {
const content = await window.electronAPI.readDocFile(file.path)
if (content) {
docs.push({ name: file.name, path: file.path, relativePath: file.relativePath, content })
}
}
setExternalDocs(docs)
const fileList = docs.map(d => d.relativePath.replace(/^api\//, ''))
const tree = buildFileTree(fileList, '/')
setFileTree(tree)
if (fileList.length > 0) {
setSelectedPath(fileList[0])
setCurrentContent(docs[0].content)
}
return true
} catch (err) {
setErrorMsg(`加载失败: ${err}`)
return false
} finally {
setIsLoading(false)
}
}, [selectedPath])
}
const handleSelect = useCallback((file: DocFile) => {
if (!file.isDir) {
setSelectedPath(file.relativePath)
const doc = externalDocs.find(d => d.relativePath === file.relativePath)
if (doc) {
setCurrentContent(doc.content)
}
}
}, [])
}, [externalDocs])
const handleReferenceClick = useCallback((ref: string) => {
const normalizedRef = ref.replace('.md', '').replace(/^\.\.\//, '')
const allFiles = Object.keys(DOCS_FILES)
const match = allFiles.find(f => f.replace('.md', '') === normalizedRef)
if (match) {
setSelectedPath(match)
const handleAddDocs = async () => {
if (isLoading) return;
const success = await loadDocsFromPath(docsPath.trim())
if (success) {
setShowModal(false)
onDocsPathChange?.(docsPath.trim())
}
}, [])
}
const handleReferenceClick = useCallback((href: string) => {
const ref = href.replace(/\.md$/, '')
const match = externalDocs.find(d => {
const docPath = d.relativePath.replace(/\.md$/, '')
return docPath === ref || docPath.endsWith('/' + ref.split('/').pop())
})
if (match) {
setSelectedPath(match.relativePath.replace(/^api\//, ''))
setCurrentContent(match.content)
}
}, [externalDocs])
return (
<div className="flex h-screen bg-[#1e1e1e]">
<div className="flex h-full bg-[#1e1e1e]">
<aside className="w-64 bg-[#252526] border-r border-[#3c3c3c] flex flex-col">
<div className="p-3 border-b border-[#3c3c3c]">
<h2 className="text-sm font-semibold text-gray-200">{config.sidebarTitle}</h2>
<div className="p-3 border-b border-[#3c3c3c] flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-200"></h2>
<button
onClick={() => {
setShowModal(true)
setErrorMsg(null)
}}
className="p-1 hover:bg-[#3c3c3c] rounded"
title="添加文档路径"
>
<Plus size={16} className="text-gray-400" />
</button>
</div>
<div className="flex-1 overflow-hidden">
<DocTree
@@ -72,10 +136,64 @@ export const ApiDocViewer = () => {
<main className="flex-1 overflow-hidden">
<DocContent
doc={currentDoc}
content={currentContent}
onReferenceClick={handleReferenceClick}
/>
</main>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#252526] rounded-lg border border-[#3c3c3c] w-96 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-200"></h3>
<button
onClick={() => setShowModal(false)}
className="p-1 hover:bg-[#3c3c3c] rounded"
>
<X size={16} className="text-gray-400" />
</button>
</div>
{errorMsg && (
<div className="mb-4 p-3 bg-red-900/30 border border-red-800 rounded flex items-start gap-2">
<AlertCircle size={16} className="text-red-400 mt-0.5 flex-shrink-0" />
<span className="text-sm text-red-300">{errorMsg}</span>
</div>
)}
<input
type="text"
value={docsPath}
onChange={(e) => {
setDocsPath(e.target.value)
setErrorMsg(null)
}}
placeholder="输入 docs 文件夹路径"
className="w-full px-3 py-2 bg-[#1e1e1e] border border-[#3c3c3c] rounded text-sm text-gray-200 placeholder-gray-500"
onKeyDown={(e) => e.key === 'Enter' && handleAddDocs()}
autoFocus
/>
<div className="text-xs text-gray-500 mb-4">
示例: C:\project\docs ( api/*.md)
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 text-sm text-gray-400 hover:text-white"
>
</button>
<button
onClick={handleAddDocs}
disabled={!docsPath.trim() || isLoading}
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed rounded text-white"
>
{isLoading ? '加载中...' : '添加'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,13 +1,14 @@
import clsx from 'clsx'
import type { ParsedDoc, DocTable, DocCodeBlock } from '@/lib/types'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { Components } from 'react-markdown'
interface DocContentProps {
doc: ParsedDoc | null
content: string
onReferenceClick: (ref: string) => void
}
export const DocContent = ({ doc, onReferenceClick }: DocContentProps) => {
if (!doc) {
export const DocContent = ({ content, onReferenceClick }: DocContentProps) => {
if (!content) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
<p>Select a document from the sidebar</p>
@@ -15,175 +16,83 @@ export const DocContent = ({ doc, onReferenceClick }: DocContentProps) => {
)
}
return (
<div className="h-full overflow-auto p-6">
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-white mb-4">{doc.title}</h1>
{doc.metadata.namespace && (
<div className="mb-4">
<span className="text-gray-400">Namespace: </span>
<code className="bg-gray-800 px-2 py-1 rounded text-blue-400">{doc.metadata.namespace}</code>
</div>
)}
{doc.metadata.package && (
<div className="mb-4">
<span className="text-gray-400">Package: </span>
<code className="bg-gray-800 px-2 py-1 rounded text-blue-400">{doc.metadata.package}</code>
</div>
)}
{doc.metadata.type && (
<div className="mb-4">
<span className="text-gray-400">Type: </span>
<span className="text-yellow-400">{doc.metadata.type}</span>
</div>
)}
{doc.metadata.inherits && (
<div className="mb-4">
<span className="text-gray-400">Inherits: </span>
<code className="bg-gray-800 px-2 py-1 rounded text-purple-400">{doc.metadata.inherits}</code>
</div>
)}
{doc.metadata.description && (
<p className="text-gray-300 mb-6">{doc.metadata.description}</p>
)}
{doc.sections.map((section, idx) => (
<Section
key={idx}
section={section}
onReferenceClick={onReferenceClick}
/>
))}
{doc.references.length > 0 && (
<div className="mt-8 pt-4 border-t border-gray-700">
<h3 className="text-lg font-semibold text-white mb-2">See Also</h3>
<ul className="space-y-1">
{doc.references.map((ref, idx) => (
<li key={idx}>
<button
onClick={() => onReferenceClick(ref)}
className="text-blue-400 hover:text-blue-300 hover:underline"
>
{ref}
</button>
</li>
))}
</ul>
</div>
)}
</div>
</div>
)
}
interface SectionProps {
section: import('@/lib/types').DocSection
onReferenceClick: (ref: string) => void
}
const Section = ({ section, onReferenceClick }: SectionProps) => {
const level = Math.min(section.level + 1, 6)
const headingClass = clsx(
'font-semibold text-white mb-3',
section.level === 1 ? 'text-xl' : 'text-lg'
)
const renderHeading = () => {
switch (level) {
case 2: return <h2 className={headingClass}>{section.title}</h2>
case 3: return <h3 className={headingClass}>{section.title}</h3>
case 4: return <h4 className={headingClass}>{section.title}</h4>
case 5: return <h5 className={headingClass}>{section.title}</h5>
case 6: return <h6 className={headingClass}>{section.title}</h6>
default: return <h2 className={headingClass}>{section.title}</h2>
}
}
return (
<section className="mb-6">
{renderHeading()}
<div className="space-y-3">
{section.content.map((item, idx) => {
if (item.type === 'table') {
return <TableContent key={idx} table={item.data as DocTable} />
}
if (item.type === 'code') {
return <CodeContent key={idx} code={item.data as DocCodeBlock} />
}
return <TextContent key={idx} text={item.data as string} onReferenceClick={onReferenceClick} />
})}
</div>
</section>
)
}
const TextContent = ({ text, onReferenceClick }: { text: string; onReferenceClick: (ref: string) => void }) => {
const renderText = () => {
const parts = text.split(/(@see\s+[^\s]+)/g)
return parts.map((part, idx) => {
const match = part.match(/@see\s+([^\s]+)/)
if (match) {
const components: Components = {
a: ({ href, children }) => {
if (href && (href.startsWith('./') || href.startsWith('../'))) {
return (
<button
key={idx}
onClick={() => onReferenceClick(match[1])}
onClick={() => onReferenceClick(href)}
className="text-blue-400 hover:text-blue-300 hover:underline"
>
{part}
{children}
</button>
)
}
return <span key={idx}>{part}</span>
})
return (
<a href={href} className="text-blue-400 hover:text-blue-300 hover:underline">
{children}
</a>
)
},
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
if (match) {
return (
<code className={className} {...props}>
{children}
</code>
)
}
return (
<code className="bg-gray-800 px-1.5 py-0.5 rounded text-blue-300 text-sm font-mono" {...props}>
{children}
</code>
)
},
pre: ({ children }) => (
<pre className="bg-gray-900 border border-gray-700 rounded-lg p-4 overflow-x-auto mb-4">
{children}
</pre>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-4">
<table className="w-full border-collapse text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-gray-800">
{children}
</thead>
),
th: ({ children }) => (
<th className="border border-gray-600 px-3 py-2 text-left text-gray-200 font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-gray-600 px-3 py-2 text-gray-300">
{children}
</td>
),
tr: ({ children }) => (
<tr className="hover:bg-gray-800/50">
{children}
</tr>
),
}
return <p className="text-gray-300">{renderText()}</p>
}
const TableContent = ({ table }: { table: DocTable }) => {
if (!table.headers.length || !table.rows.length) return null
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-800">
{table.headers.map((header, idx) => (
<th key={idx} className="border border-gray-600 px-3 py-2 text-left text-gray-200 font-semibold">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map((row, rowIdx) => (
<tr key={rowIdx} className="hover:bg-gray-800/50">
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="border border-gray-600 px-3 py-2 text-gray-300">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="h-full overflow-auto p-6">
<div className="max-w-4xl mx-auto text-gray-200">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={components}
>
{content}
</ReactMarkdown>
</div>
</div>
)
}
const CodeContent = ({ code }: { code: DocCodeBlock }) => {
return (
<pre className="bg-gray-900 border border-gray-700 rounded-lg p-4 overflow-x-auto">
<code className={`language-${code.language || 'text'} text-sm text-gray-200`}>
{code.code}
</code>
</pre>
)
}

View File

@@ -1,7 +1,31 @@
import Scene3D from './Scene3D';
import DetailPanel from './DetailPanel';
import { useBlueprintStore } from '../../store/blueprintStore';
import { useEffect, useCallback } from 'react';
import { parseBlueprintFromMd } from '../../data/blueprintParser';
export default function BlueprintPage() {
interface BlueprintPageProps {
docsPath: string;
}
export default function BlueprintPage({ docsPath }: BlueprintPageProps) {
const setBlueprintData = useBlueprintStore(state => state.setBlueprintData);
const loadBlueprint = useCallback(async () => {
if (!docsPath || !window.electronAPI) return;
const blueprintPath = docsPath.replace(/\\/g, '/') + '/blueprint.md';
const content = await window.electronAPI.readDocFile(blueprintPath);
if (content) {
const data = parseBlueprintFromMd(content);
setBlueprintData(data);
}
}, [docsPath, setBlueprintData]);
useEffect(() => {
loadBlueprint();
}, [loadBlueprint]);
return (
<div className="flex h-full">
<div className="flex-1">