修复: OSS收藏打开+连接指示器根目录+字段映射+侧边栏重构+文件监听+首屏优化

This commit is contained in:
lxy
2026-05-16 17:58:41 +08:00
parent 316e517989
commit d17c20c579
37 changed files with 1667 additions and 1566 deletions
+182 -165
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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')
}
+29 -29
View File
@@ -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)