feat(ui): add file explorer, TTS UI, views and routing
Add a file tree UI and corresponding composable for local file management. Introduce TTS menu and player components for voice synthesis integration. Add new EditorView and DocsView routes and update SettingsPanel view switching. Enhance Mermaid plugin with improved styling and action buttons.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
let mermaidReadyTheme = ''
|
||||
@@ -24,14 +24,37 @@ function ensureMermaid() {
|
||||
const theme = getMermaidTheme()
|
||||
if (mermaidReadyTheme === theme) return
|
||||
|
||||
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
const dark = theme === 'dark'
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme || (dark ? 'dark' : 'default'),
|
||||
theme: dark ? 'dark' : 'base',
|
||||
securityLevel: 'loose',
|
||||
fontFamily: 'inherit',
|
||||
flowchart: {
|
||||
htmlLabels: false,
|
||||
flowchart: { htmlLabels: false },
|
||||
themeVariables: dark ? {
|
||||
primaryColor: '#1e2d45',
|
||||
primaryTextColor: '#c9d6e8',
|
||||
primaryBorderColor: '#3b5278',
|
||||
lineColor: '#5a7aa8',
|
||||
secondaryColor: '#162236',
|
||||
tertiaryColor: '#0f1926',
|
||||
edgeLabelBackground: '#1a2a40',
|
||||
clusterBkg: '#111e2e',
|
||||
titleColor: '#c9d6e8',
|
||||
nodeBorder: '#3b5278',
|
||||
mainBkg: '#1e2d45',
|
||||
} : {
|
||||
primaryColor: '#e8f0fe',
|
||||
primaryTextColor: '#1e3a5f',
|
||||
primaryBorderColor: '#93b4d9',
|
||||
lineColor: '#4a7cb5',
|
||||
secondaryColor: '#dbeafe',
|
||||
tertiaryColor: '#f0f7ff',
|
||||
edgeLabelBackground: '#f0f7ff',
|
||||
clusterBkg: '#f5f8ff',
|
||||
titleColor: '#1e3a5f',
|
||||
nodeBorder: '#93b4d9',
|
||||
mainBkg: '#e8f0fe',
|
||||
},
|
||||
})
|
||||
mermaidReadyTheme = theme
|
||||
@@ -69,17 +92,26 @@ function makeMermaidFilename() {
|
||||
}
|
||||
|
||||
function buildMermaidPreviewMarkup(code: string, token: number) {
|
||||
const encoded = encodeMermaidCode(code)
|
||||
// 剥离首尾的 ```mermaid 或 ``` 标识符,防止其被误认为图表节点
|
||||
const cleanCode = code
|
||||
.replace(/^```[a-z]*\s*\n?/i, '')
|
||||
.replace(/\n?```\s*$/i, '')
|
||||
.trim()
|
||||
|
||||
const encoded = encodeMermaidCode(cleanCode)
|
||||
const filename = makeMermaidFilename()
|
||||
|
||||
const zoomSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`
|
||||
const dlSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`
|
||||
|
||||
return `
|
||||
<div class="mermaid-block" data-mermaid-code="${encoded}" data-mermaid-token="${token}">
|
||||
<div class="mermaid-controls">
|
||||
<button type="button" class="mermaid-action-btn" data-mermaid-action="zoom" disabled>Zoom</button>
|
||||
<button type="button" class="mermaid-action-btn" data-mermaid-action="download" data-mermaid-filename="${filename}" disabled>Download PNG</button>
|
||||
<button type="button" class="mermaid-action-btn mermaid-zoom-btn" data-mermaid-action="zoom" disabled aria-label="放大查看">${zoomSvg}</button>
|
||||
<button type="button" class="mermaid-action-btn mermaid-download-btn" data-mermaid-action="download" data-mermaid-filename="${filename}" disabled aria-label="下载图表">${dlSvg}<span>下载</span></button>
|
||||
</div>
|
||||
<div class="mermaid-inner">
|
||||
<div class="mermaid-loading">...</div>
|
||||
<div class="mermaid-loading"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>`.trim()
|
||||
}
|
||||
@@ -102,7 +134,6 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
|
||||
}
|
||||
|
||||
if (action === 'download') {
|
||||
node.textContent = 'Download PNG'
|
||||
if (payload) {
|
||||
node.removeAttribute('disabled')
|
||||
node.setAttribute('data-mermaid-url', payload.downloadUrl || payload.previewUrl)
|
||||
@@ -130,6 +161,7 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
|
||||
}
|
||||
|
||||
function getSvgSize(svg: string) {
|
||||
// 优先读取 viewBox (取第三、四个值作为宽高)
|
||||
const viewBox = svg.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
|
||||
if (viewBox) {
|
||||
const width = Number(viewBox[1])
|
||||
@@ -139,14 +171,16 @@ function getSvgSize(svg: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const widthAttr = svg.match(/width\s*=\s*["']([-\d.]+)(px)?["']/i)
|
||||
const heightAttr = svg.match(/height\s*=\s*["']([-\d.]+)(px)?["']/i)
|
||||
const width = widthAttr ? Number(widthAttr[1]) : 960
|
||||
const height = heightAttr ? Number(heightAttr[1]) : 540
|
||||
return {
|
||||
width: Number.isFinite(width) && width > 0 ? width : 960,
|
||||
height: Number.isFinite(height) && height > 0 ? height : 540,
|
||||
// 次选读取数字像素属性
|
||||
const widthAttr = svg.match(/\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/i)
|
||||
const heightAttr = svg.match(/\bheight\s*=\s*["']([\d.]+)(?:px)?["']/i)
|
||||
const attrW = widthAttr ? Number(widthAttr[1]) : 0
|
||||
const attrH = heightAttr ? Number(heightAttr[1]) : 0
|
||||
if (attrW > 0 && attrH > 0) {
|
||||
return { width: attrW, height: attrH }
|
||||
}
|
||||
|
||||
return { width: 960, height: 540 }
|
||||
}
|
||||
|
||||
function svgToDataUrl(svg: string) {
|
||||
@@ -159,6 +193,38 @@ function stripExternalSvgResources(svg: string) {
|
||||
.replace(/url\(\s*["']?https?:\/\/[^"')]*["']?\s*\)/gi, 'none')
|
||||
}
|
||||
|
||||
/** 给 SVG viewBox 四周加 padding,同步更新 width/height 并注入字体样式防止截断 */
|
||||
function padSvgViewBox(svg: string, pad = 48): string {
|
||||
let result = svg
|
||||
|
||||
// 注入显式字体样式,确保测量与渲染一致
|
||||
const styleInject = `
|
||||
<style>
|
||||
svg { font-family: 'Inter', system-ui, sans-serif !important; }
|
||||
.node text, .edgeLabel text { font-family: 'Inter', system-ui, sans-serif !important; }
|
||||
</style>`
|
||||
if (result.includes('</style>')) {
|
||||
result = result.replace('</style>', ` svg { font-family: 'Inter', system-ui, sans-serif !important; }\n .node text, .edgeLabel text { font-family: 'Inter', system-ui, sans-serif !important; }\n</style>`)
|
||||
} else {
|
||||
result = result.replace(/>/, `>${styleInject}`)
|
||||
}
|
||||
|
||||
// 匹配 viewBox
|
||||
const vbMatch = result.match(/viewBox\s*=\s*["']\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
|
||||
if (vbMatch) {
|
||||
const x = parseFloat(vbMatch[1]) - pad
|
||||
const y = parseFloat(vbMatch[2]) - pad
|
||||
const w = parseFloat(vbMatch[3]) + pad * 2
|
||||
const h = parseFloat(vbMatch[4]) + pad * 2
|
||||
result = result.replace(vbMatch[0], `viewBox="${x} ${y} ${w} ${h}"`)
|
||||
|
||||
// 覆盖外层 width/height 为实际像素值
|
||||
result = result.replace(/\bwidth\s*=\s*["'][^"']*["']/i, `width="${w}"`)
|
||||
result = result.replace(/\bheight\s*=\s*["'][^"']*["']/i, `height="${h}"`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getRasterDpr(width: number, height: number) {
|
||||
const rawDpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
|
||||
const baseDpr = Math.min(3, Math.max(1, rawDpr))
|
||||
@@ -196,7 +262,8 @@ async function rasterizeSvgToPngDataUrl(svg: string, width: number, height: numb
|
||||
}
|
||||
|
||||
async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
|
||||
const fallback = getSvgSize(svg)
|
||||
const paddedSvg = padSvgViewBox(svg)
|
||||
const fallback = getSvgSize(paddedSvg)
|
||||
const sourceWidth = fallback.width
|
||||
const sourceHeight = fallback.height
|
||||
|
||||
@@ -208,8 +275,8 @@ async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
|
||||
width = Math.max(1, Math.round(width * scale))
|
||||
height = Math.max(1, Math.round(height * scale))
|
||||
|
||||
const normalizedSvg = stripExternalSvgResources(svg)
|
||||
const candidates = normalizedSvg === svg ? [svg] : [svg, normalizedSvg]
|
||||
const normalizedSvg = stripExternalSvgResources(paddedSvg)
|
||||
const candidates = normalizedSvg === paddedSvg ? [paddedSvg] : [paddedSvg, normalizedSvg]
|
||||
let lastError: unknown = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -270,7 +337,7 @@ async function renderMermaidBlock(block: HTMLElement, token: number): Promise<vo
|
||||
const encodedCode = block.getAttribute('data-mermaid-code') || ''
|
||||
const code = decodeMermaidCode(encodedCode).trim() || 'graph TD\nA-->B'
|
||||
|
||||
inner.innerHTML = '<div class="mermaid-loading">...</div>'
|
||||
inner.innerHTML = '<div class="mermaid-loading"><span></span><span></span><span></span></div>'
|
||||
setMermaidActionsState(block, null)
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user