修复: OSS收藏打开+连接指示器根目录+字段映射+侧边栏重构+文件监听+首屏优化
This commit is contained in:
@@ -1,20 +1,176 @@
|
||||
import { marked } from 'marked'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/lib/common'
|
||||
// 按需导入 common 包不包含的语言
|
||||
import 'highlight.js/lib/languages/powershell'
|
||||
import 'highlight.js/lib/languages/dos'
|
||||
import 'highlight.js/lib/languages/autohotkey'
|
||||
import 'highlight.js/lib/languages/latex'
|
||||
import 'highlight.js/lib/languages/dockerfile'
|
||||
import 'highlight.js/lib/languages/cmake'
|
||||
import 'highlight.js/lib/languages/scala'
|
||||
import 'highlight.js/lib/languages/dart'
|
||||
import { getHljsLanguage } from './languageMap'
|
||||
|
||||
let mermaidInstance: typeof import('mermaid').default | null = null
|
||||
let mermaidTheme: string | null = null
|
||||
|
||||
// 懒加载 marked + hljs,首次调用时才初始化
|
||||
let _marked: typeof import('marked').marked | null = null
|
||||
let _initPromise: Promise<typeof import('marked').marked> | null = null
|
||||
|
||||
async function ensureMarked(): Promise<typeof import('marked').marked> {
|
||||
if (_marked) return _marked
|
||||
if (_initPromise) return _initPromise
|
||||
|
||||
_initPromise = (async () => {
|
||||
const [markedModule, hljs, ...extras] = await Promise.all([
|
||||
import('marked'),
|
||||
import('highlight.js'),
|
||||
import('highlight.js/lib/languages/powershell'),
|
||||
import('highlight.js/lib/languages/dos'),
|
||||
import('highlight.js/lib/languages/autohotkey'),
|
||||
import('highlight.js/lib/languages/latex'),
|
||||
import('highlight.js/lib/languages/dockerfile'),
|
||||
import('highlight.js/lib/languages/cmake'),
|
||||
import('highlight.js/lib/languages/scala'),
|
||||
import('highlight.js/lib/languages/dart'),
|
||||
])
|
||||
// 注册额外语言到 hljs
|
||||
const langNames = ['powershell', 'dos', 'autohotkey', 'latex', 'dockerfile', 'cmake', 'scala', 'dart']
|
||||
extras.forEach((mod, i) => {
|
||||
hljs.default.registerLanguage(langNames[i], (mod as any).default)
|
||||
})
|
||||
|
||||
const marked = markedModule.marked
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.code = function(token: any) {
|
||||
if (token.lang === 'mermaid') {
|
||||
return `<pre class="mermaid">${token.text}</pre>`
|
||||
}
|
||||
|
||||
const lang = getHljsLanguage(token.lang)
|
||||
|
||||
let highlighted: string
|
||||
try {
|
||||
highlighted = hljs.default.highlight(token.text, { language: lang }).value
|
||||
} catch {
|
||||
highlighted = token.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
renderer.heading = function(token: any) {
|
||||
const raw = token.raw || ''
|
||||
const depth = token.depth || 1
|
||||
const text = token.text || ''
|
||||
|
||||
const id = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^一-龥a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || `heading-${Math.random().toString(36).slice(2, 11)}`
|
||||
|
||||
return `<h${depth} id="${id}" class="heading">
|
||||
${text}<a href="#${id}" class="heading-anchor" aria-hidden="true" title="跳转到此标题">#</a>
|
||||
</h${depth}>`
|
||||
}
|
||||
|
||||
// 图片/链接渲染器
|
||||
renderer.image = createImageRenderer()
|
||||
renderer.link = createLinkRenderer()
|
||||
|
||||
marked.use({ renderer, breaks: true, gfm: true, async: false })
|
||||
|
||||
_marked = marked
|
||||
return marked
|
||||
})()
|
||||
|
||||
return _initPromise
|
||||
}
|
||||
|
||||
// ========== 图片相对路径转换支持 ==========
|
||||
let _currentFileDir: string = ''
|
||||
let _fileServerBase: string = 'http://localhost:2652/localfs'
|
||||
|
||||
export function setCurrentFileDir(dir: string): void {
|
||||
_currentFileDir = dir
|
||||
}
|
||||
|
||||
export function getCurrentFileDir(): string {
|
||||
return _currentFileDir
|
||||
}
|
||||
|
||||
export function setFileServerBase(base: string): void {
|
||||
_fileServerBase = base
|
||||
}
|
||||
|
||||
function resolveImageUrl(src: string, fileServerBase: string): string {
|
||||
if (!src) return src
|
||||
if (/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) return src
|
||||
|
||||
const dir = _currentFileDir || '/'
|
||||
const sep = dir.includes('\\') ? '\\' : '/'
|
||||
let resolved = normalizeRelativePath(dir, src, sep)
|
||||
|
||||
const encoded = encodeURIComponent(resolved).replace(/%2F/gi, '/').replace(/%5C/gi, '\\')
|
||||
return `${fileServerBase}/${encoded}`
|
||||
}
|
||||
|
||||
function normalizeRelativePath(base: string, relative: string, sep: string): string {
|
||||
let baseNormalized = base.replace(/[\\/]+$/, '')
|
||||
if (!baseNormalized) baseNormalized = sep === '/' ? '/' : 'C:\\'
|
||||
|
||||
const baseParts = baseNormalized.split(sep).filter(Boolean)
|
||||
const relParts = relative.split(/[\\/]/).filter(Boolean)
|
||||
|
||||
for (const part of relParts) {
|
||||
if (part === '..') {
|
||||
baseParts.pop()
|
||||
} else if (part !== '.') {
|
||||
baseParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z]:$/i.test(baseNormalized.split(sep)[0] || '')) {
|
||||
return baseParts.join(sep)
|
||||
}
|
||||
return sep + baseParts.join(sep)
|
||||
}
|
||||
|
||||
const isLocalFileLink = (href: string): boolean => {
|
||||
if (!href) return false
|
||||
if (/^(https?|ftp|mailto|tel|data):/i.test(href)) return false
|
||||
if (href.startsWith('#')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function createImageRenderer() {
|
||||
return function(token: any) {
|
||||
const src = token.href || ''
|
||||
const title = token.title || ''
|
||||
const alt = token.text || ''
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
|
||||
if (_currentFileDir && !/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) {
|
||||
const resolvedSrc = resolveImageUrl(src, _fileServerBase)
|
||||
return `<img src="${resolvedSrc}" alt="${alt}"${titleAttr}>`
|
||||
}
|
||||
|
||||
return `<img src="${src}" alt="${alt}"${titleAttr}>`
|
||||
}
|
||||
}
|
||||
|
||||
function createLinkRenderer() {
|
||||
return function(this: any, token: any) {
|
||||
const href = token.href || ''
|
||||
const text = this.parser.parseInline(token.tokens) || token.text || ''
|
||||
const title = token.title || ''
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return `<a href="${href}${titleAttr}">${text}</a>`
|
||||
}
|
||||
|
||||
if (isLocalFileLink(href)) {
|
||||
return `<a href="javascript:void(0)" data-local-link="${href}" class="local-file-link"${titleAttr}>${text}</a>`
|
||||
}
|
||||
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||
}
|
||||
}
|
||||
|
||||
// 检测当前是否为暗色主题
|
||||
function isDarkTheme(): boolean {
|
||||
if (typeof document === 'undefined') return false
|
||||
@@ -64,167 +220,31 @@ async function loadMermaid() {
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.code = function(token: any) {
|
||||
if (token.lang === 'mermaid') {
|
||||
return `<pre class="mermaid">${token.text}</pre>`
|
||||
}
|
||||
|
||||
const lang = getHljsLanguage(token.lang)
|
||||
|
||||
let highlighted: string
|
||||
try {
|
||||
highlighted = hljs.highlight(token.text, { language: lang }).value
|
||||
} catch {
|
||||
highlighted = token.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
|
||||
/** 同步渲染 Markdown(需先调用 ensureMarked 初始化) */
|
||||
export async function renderMarkdown(content: string): Promise<string> {
|
||||
const marked = await ensureMarked()
|
||||
return marked.parse(content) as string
|
||||
}
|
||||
|
||||
renderer.heading = function(token: any) {
|
||||
const raw = token.raw || ''
|
||||
const depth = token.depth || 1
|
||||
const text = token.text || ''
|
||||
|
||||
const id = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^\u4e00-\u9fa5a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || `heading-${Math.random().toString(36).slice(2, 11)}`
|
||||
|
||||
return `<h${depth} id="${id}" class="heading">
|
||||
${text}<a href="#${id}" class="heading-anchor" aria-hidden="true" title="跳转到此标题">#</a>
|
||||
</h${depth}>`
|
||||
/** 检查 marked 是否已初始化(同步渲染用) */
|
||||
export function isMarkedReady(): boolean {
|
||||
return _marked !== null
|
||||
}
|
||||
|
||||
// ========== 图片相对路径转换支持 ==========
|
||||
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
|
||||
let _currentFileDir: string = ''
|
||||
// 文件服务器 Base URL(由调用方在渲染前设置)
|
||||
let _fileServerBase: string = 'http://localhost:2652/localfs'
|
||||
|
||||
/**
|
||||
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
|
||||
* @param dir 文件所在目录的绝对路径,如 "D:/docs" 或 "/"(根目录)
|
||||
*/
|
||||
export function setCurrentFileDir(dir: string): void {
|
||||
_currentFileDir = dir
|
||||
/** 同步渲染(仅当已初始化时可用) */
|
||||
export function parseMarkdown(content: string): string {
|
||||
if (!_marked) return ''
|
||||
return _marked.parse(content) as string
|
||||
}
|
||||
|
||||
/** 获取当前设置的文件目录 */
|
||||
export function getCurrentFileDir(): string {
|
||||
return _currentFileDir
|
||||
/** 触发懒加载(可在空闲时调用) */
|
||||
export function preloadMarked(): void {
|
||||
ensureMarked().catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文件服务器 Base URL(用于图片相对路径转换)
|
||||
* @param base 完整的 base URL 前缀,如 "http://localhost:2652/localfs" 或 "https://host:port/api/v1/proxy/localfs"
|
||||
*/
|
||||
export function setFileServerBase(base: string): void {
|
||||
_fileServerBase = base
|
||||
}
|
||||
|
||||
/**
|
||||
* 将相对路径图片 src 解析为文件服务器 URL
|
||||
* - 绝对路径(Windows: D:/...、Unix: /usr/...)、网络URL、data URI → 不转换
|
||||
* - 相对路径 → 基于当前文件目录解析为绝对路径,再编码为文件服务器 URL
|
||||
*/
|
||||
function resolveImageUrl(src: string, fileServerBase: string): string {
|
||||
if (!src) return src
|
||||
// 不转换:绝对路径(Windows 盘符)、网络协议、锚点、data URI
|
||||
if (/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) return src
|
||||
|
||||
// 解析相对路径(处理 ../ 和 ./)
|
||||
const dir = _currentFileDir || '/'
|
||||
const sep = dir.includes('\\') ? '\\' : '/'
|
||||
let resolved = normalizeRelativePath(dir, src, sep)
|
||||
|
||||
// 编码路径(保留 / 分隔符)
|
||||
const encoded = encodeURIComponent(resolved).replace(/%2F/gi, '/').replace(/%5C/gi, '\\')
|
||||
return `${fileServerBase}/${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化相对路径,处理 .. 和 . 段
|
||||
*/
|
||||
function normalizeRelativePath(base: string, relative: string, sep: string): string {
|
||||
// 确保基础路径不以分隔符结尾
|
||||
let baseNormalized = base.replace(/[\\/]+$/, '')
|
||||
if (!baseNormalized) baseNormalized = sep === '/' ? '/' : 'C:\\'
|
||||
|
||||
const baseParts = baseNormalized.split(sep).filter(Boolean)
|
||||
const relParts = relative.split(/[\\/]/).filter(Boolean)
|
||||
|
||||
for (const part of relParts) {
|
||||
if (part === '..') {
|
||||
baseParts.pop() // 向上一级
|
||||
} else if (part !== '.') {
|
||||
baseParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
// 重建路径:Windows 绝对路径保留盘符前缀
|
||||
if (/^[a-zA-Z]:$/i.test(baseNormalized.split(sep)[0] || '')) {
|
||||
return baseParts.join(sep)
|
||||
}
|
||||
// Unix 风格:以 / 开头
|
||||
return sep + baseParts.join(sep)
|
||||
}
|
||||
|
||||
// 判断是否为本地文件链接(相对路径或本地绝对路径)
|
||||
const isLocalFileLink = (href: string): boolean => {
|
||||
if (!href) return false
|
||||
if (/^(https?|ftp|mailto|tel|data):/i.test(href)) return false
|
||||
if (href.startsWith('#')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// 自定义图片渲染器 - 转换相对路径为文件服务器 URL
|
||||
renderer.image = function(token: any) {
|
||||
const src = token.href || ''
|
||||
const title = token.title || ''
|
||||
const alt = token.text || ''
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
|
||||
// 判断是否需要转换(仅处理相对路径,且当前目录已设置)
|
||||
if (_currentFileDir && !/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) {
|
||||
const resolvedSrc = resolveImageUrl(src, _fileServerBase)
|
||||
return `<img src="${resolvedSrc}" alt="${alt}"${titleAttr}>`
|
||||
}
|
||||
|
||||
// 默认渲染(绝对路径 / 网络 URL / data URI / 未设置目录时原样输出)
|
||||
return `<img src="${src}" alt="${alt}"${titleAttr}>`
|
||||
}
|
||||
|
||||
// 自定义链接渲染器 - 支持本地文件链接
|
||||
renderer.link = function(token: any) {
|
||||
const href = token.href || ''
|
||||
const text = this.parser.parseInline(token.tokens) || token.text || ''
|
||||
const title = token.title || ''
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return `<a href="${href}${titleAttr}">${text}</a>`
|
||||
}
|
||||
|
||||
if (isLocalFileLink(href)) {
|
||||
return `<a href="javascript:void(0)" data-local-link="${href}" class="local-file-link"${titleAttr}>${text}</a>`
|
||||
}
|
||||
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||
}
|
||||
|
||||
marked.use({ renderer, breaks: true, gfm: true, async: false })
|
||||
|
||||
export { marked }
|
||||
|
||||
export async function renderMermaidDiagrams() {
|
||||
const mermaid = await loadMermaid()
|
||||
if (mermaid) {
|
||||
// 渲染前保存原始源码(textContent 在 SVG 渲染后会变成 CSS 垃圾)
|
||||
document.querySelectorAll('.mermaid:not([data-mermaid-src])').forEach(pre => {
|
||||
;(pre as HTMLElement).setAttribute('data-mermaid-src', pre.textContent || '')
|
||||
})
|
||||
@@ -232,9 +252,7 @@ export async function renderMermaidDiagrams() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除已渲染内容并重新渲染(用于主题切换后刷新) */
|
||||
export async function rerenderMermaidDiagrams(container?: HTMLElement | null) {
|
||||
// 强制重新加载(清除缓存,让下次 loadMermaid 重新初始化新主题)
|
||||
mermaidInstance = null
|
||||
mermaidTheme = null
|
||||
|
||||
@@ -242,7 +260,6 @@ export async function rerenderMermaidDiagrams(container?: HTMLElement | null) {
|
||||
target.querySelectorAll('.mermaid').forEach(pre => {
|
||||
const el = pre as HTMLElement
|
||||
if (el.getAttribute('data-processed')) {
|
||||
// 从保存的原始源码恢复,而非 textContent(SVG 的 textContent 是 CSS 垃圾)
|
||||
el.innerHTML = el.getAttribute('data-mermaid-src') || ''
|
||||
el.removeAttribute('data-processed')
|
||||
}
|
||||
|
||||
@@ -52,52 +52,52 @@ export function createResizeHandler(
|
||||
const container = getContainer()
|
||||
if (!container) return
|
||||
|
||||
// 初始值:pixels 模式下从 getter 获取像素值,percent 模式下获取百分比
|
||||
// mousedown 时缓存容器尺寸,避免每帧 getBoundingClientRect 强制回流
|
||||
const cachedRect = container.getBoundingClientRect()
|
||||
const containerSize = isHorizontal ? cachedRect.width : cachedRect.height
|
||||
|
||||
// 初始值
|
||||
let startValue = getInitialPercentage()
|
||||
if (usePixels) {
|
||||
// pixels 模式:将初始像素值转换为百分比用于拖拽计算
|
||||
const rect = container.getBoundingClientRect()
|
||||
const containerSize = isHorizontal ? rect.width : rect.height
|
||||
if (containerSize > 0) {
|
||||
startValue = (startValue / containerSize) * 100
|
||||
}
|
||||
}
|
||||
|
||||
const minPercentFromPixels = containerSize > 0 ? (minPixels / containerSize) * 100 : 0
|
||||
|
||||
let rafId = 0
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const currentRect = container.getBoundingClientRect()
|
||||
let rawValue: number
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(() => {
|
||||
let rawValue: number
|
||||
|
||||
if (isHorizontal) {
|
||||
rawValue = ((moveEvent.clientX - currentRect.left) / currentRect.width) * 100
|
||||
} else {
|
||||
rawValue = ((moveEvent.clientY - currentRect.top) / currentRect.height) * 100
|
||||
}
|
||||
if (isHorizontal) {
|
||||
rawValue = ((moveEvent.clientX - cachedRect.left) / containerSize) * 100
|
||||
} else {
|
||||
rawValue = ((moveEvent.clientY - cachedRect.top) / containerSize) * 100
|
||||
}
|
||||
|
||||
const minPercentFromPixels = (minPixels / (isHorizontal ? currentRect.width : currentRect.height)) * 100
|
||||
const clamped = Math.max(
|
||||
Math.max(minPercent, minPercentFromPixels),
|
||||
Math.min(maxPercent, rawValue)
|
||||
)
|
||||
const clamped = Math.max(
|
||||
Math.max(minPercent, minPercentFromPixels),
|
||||
Math.min(maxPercent, rawValue)
|
||||
)
|
||||
|
||||
if (usePixels) {
|
||||
// 转回像素值传给回调
|
||||
const containerSize = isHorizontal ? currentRect.width : currentRect.height
|
||||
onResize?.(Math.round((clamped / 100) * containerSize))
|
||||
} else {
|
||||
onResize?.(clamped)
|
||||
}
|
||||
if (usePixels) {
|
||||
onResize?.(Math.round((clamped / 100) * containerSize))
|
||||
} else {
|
||||
onResize?.(clamped)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.classList.remove('resizing')
|
||||
if (usePixels) {
|
||||
// pixels 模式:传回当前像素值(onResize 已更新,读 getter 获取最新值)
|
||||
onResizeEnd?.(getInitialPercentage())
|
||||
} else {
|
||||
onResizeEnd?.(getInitialPercentage())
|
||||
}
|
||||
onResizeEnd?.(getInitialPercentage())
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
Reference in New Issue
Block a user