Initial commit

This commit is contained in:
2026-03-08 01:34:54 +08:00
commit 1f104f73c8
441 changed files with 64911 additions and 0 deletions

122
src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,122 @@
import type { FileContentDTO, FileItemDTO, PathExistsDTO, SettingsDTO } from '@shared/types'
import { fetchApi } from './http'
export type FileItem = FileItemDTO
export type FileContent = FileContentDTO
export type Settings = SettingsDTO
export type UploadedImage = { name: string; path: string }
export const searchFiles = async (keywords: string[]): Promise<FileItem[]> => {
const data = await fetchApi<{ items: FileItem[] }>('/api/search', {
method: 'POST',
body: { keywords },
})
return data.items
}
export const fetchFiles = async (path: string = ''): Promise<FileItem[]> => {
const data = await fetchApi<{ items: FileItem[] }>(`/api/files?path=${encodeURIComponent(path)}`)
return data.items
}
export const fetchFileContent = async (path: string): Promise<FileContent> => {
return await fetchApi<FileContent>(`/api/files/content?path=${encodeURIComponent(path)}`)
}
export const saveFileContent = async (path: string, content: string): Promise<void> => {
await fetchApi<null>('/api/files/save', {
method: 'POST',
body: { path, content },
})
}
export const uploadClipboardImage = async (image: string): Promise<UploadedImage> => {
return await fetchApi<UploadedImage>('/api/files/upload/image', {
method: 'POST',
body: { image },
})
}
export const deleteFile = async (path: string): Promise<void> => {
await fetchApi<null>('/api/files/delete', {
method: 'DELETE',
body: { path },
})
}
export const createDirectory = async (path: string): Promise<void> => {
await fetchApi<null>('/api/files/create/dir', {
method: 'POST',
body: { path },
})
}
export const createFile = async (path: string): Promise<void> => {
await fetchApi<null>('/api/files/create/file', {
method: 'POST',
body: { path },
})
}
export const checkPathExists = async (path: string): Promise<PathExistsDTO> => {
return await fetchApi<PathExistsDTO>('/api/files/exists', {
method: 'POST',
body: { path },
})
}
export const renameItem = async (oldPath: string, newPath: string): Promise<void> => {
await fetchApi<null>('/api/files/rename', {
method: 'POST',
body: { oldPath, newPath },
})
}
export const runAiTask = async (task: string, path: string): Promise<void> => {
await fetchApi<null>('/api/ai/doubao', {
method: 'POST',
body: { task, path },
})
}
export const getSettings = async (): Promise<Settings> => {
return await fetchApi<Settings>('/api/settings')
}
export const saveSettings = async (settings: Settings): Promise<Settings> => {
return await fetchApi<Settings>('/api/settings', {
method: 'POST',
body: settings,
})
}
export const uploadPdfForParsing = async (file: File, targetPath: string): Promise<void> => {
const formData = new FormData()
formData.append('file', file)
formData.append('targetPath', targetPath)
await fetchApi<void>('/api/mineru/parse', {
method: 'POST',
body: formData,
})
}
export interface LocalHtmlInfo {
htmlPath: string
htmlDir: string
assetsDirName?: string
assetsFiles?: string[]
}
export const parseLocalHtml = async (info: LocalHtmlInfo, targetPath: string): Promise<void> => {
await fetchApi<void>('/api/blog/parse-local', {
method: 'POST',
body: {
htmlPath: info.htmlPath,
htmlDir: info.htmlDir,
assetsDirName: info.assetsDirName,
assetsFiles: info.assetsFiles,
targetPath,
},
})
}

View File

@@ -0,0 +1,104 @@
import { fetchApi } from './http'
import type { RequestOptions } from './modules/types'
import type { ModuleDefinition, ModuleEndpoints } from '@shared/modules/types'
export interface EndpointConfig {
path: string
method: string
}
export class ModuleApiInstance<TEndpoints extends ModuleEndpoints> {
readonly moduleId: string
readonly basePath: string
readonly endpoints: TEndpoints
constructor(definition: ModuleDefinition<string, TEndpoints>) {
this.moduleId = definition.id
this.basePath = definition.basePath
this.endpoints = definition.endpoints ?? ({} as TEndpoints)
}
protected buildPath(path: string, pathParams?: Record<string, string | number>): string {
let result = path
if (pathParams) {
for (const [key, value] of Object.entries(pathParams)) {
result = result.replace(`:${key}`, String(value))
}
}
return result
}
protected buildQueryParams(params?: Record<string, string | number | boolean | undefined>): string {
if (!params) return ''
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
searchParams.append(key, String(value))
}
}
const queryString = searchParams.toString()
return queryString ? `?${queryString}` : ''
}
protected getEndpointConfig(endpoint: keyof TEndpoints): EndpointConfig {
const ep = this.endpoints[endpoint]
if (!ep) {
throw new Error(`Endpoint '${String(endpoint)}' not found in module '${this.moduleId}'`)
}
return {
path: ep.path,
method: ep.method,
}
}
protected buildUrl(endpoint: keyof TEndpoints, options?: RequestOptions): string {
const endpointConfig = this.getEndpointConfig(endpoint)
const fullPath = `/api${this.basePath}${endpointConfig.path}`
const path = this.buildPath(fullPath, options?.pathParams)
const queryString = this.buildQueryParams(options?.queryParams)
return `${path}${queryString}`
}
async get<T>(endpoint: keyof TEndpoints, options?: RequestOptions): Promise<T | undefined> {
const url = this.buildUrl(endpoint, options)
return fetchApi<T>(url)
}
async post<T>(endpoint: keyof TEndpoints, body: unknown, options?: RequestOptions): Promise<T | undefined> {
const url = this.buildUrl(endpoint, options)
return fetchApi<T>(url, {
method: 'POST',
body,
})
}
async put<T>(endpoint: keyof TEndpoints, body: unknown, options?: RequestOptions): Promise<T | undefined> {
const url = this.buildUrl(endpoint, options)
return fetchApi<T>(url, {
method: 'PUT',
body,
})
}
async delete<T>(endpoint: keyof TEndpoints, body?: unknown, options?: RequestOptions): Promise<T | undefined> {
const url = this.buildUrl(endpoint, options)
return fetchApi<T>(url, {
method: 'DELETE',
body,
})
}
async postFormData<T>(endpoint: keyof TEndpoints, formData: FormData): Promise<T | undefined> {
const url = this.buildUrl(endpoint)
return fetchApi<T>(url, {
method: 'POST',
body: formData,
})
}
}
export function createModuleApi<TEndpoints extends ModuleEndpoints>(
definition: ModuleDefinition<string, TEndpoints>
): ModuleApiInstance<TEndpoints> {
return new ModuleApiInstance(definition)
}

71
src/lib/api/http.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { ApiResponse, ApiErrorDTO } from '@shared/types'
export class HttpError extends Error {
status: number
code: string
details?: unknown
constructor(options: { status: number; code: string; message: string; details?: unknown }) {
super(options.message)
this.status = options.status
this.code = options.code
this.details = options.details
}
}
const isFailure = <T>(body: ApiResponse<T>): body is { success: false; error: ApiErrorDTO } => body.success === false
export const fetchApi = async <T>(input: RequestInfo | URL, init?: RequestInit | (Omit<RequestInit, 'body'> & { body?: unknown })): Promise<T | undefined> => {
const headers = new Headers(init?.headers)
let body = init?.body
if (
body &&
typeof body === 'object' &&
!(body instanceof FormData) &&
!(body instanceof Blob) &&
!(body instanceof URLSearchParams) &&
!(body instanceof ReadableStream) &&
!(body instanceof ArrayBuffer) &&
!ArrayBuffer.isView(body)
) {
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
body = JSON.stringify(body)
}
const res = await fetch(input, { ...init, headers, body: body as BodyInit })
const isJson = res.headers.get('content-type')?.includes('application/json')
if (!isJson) {
if (!res.ok) {
throw new HttpError({ status: res.status, code: 'HTTP_ERROR', message: res.statusText })
}
return undefined
}
const responseBody = (await res.json()) as ApiResponse<T>
if (!res.ok) {
if (isFailure(responseBody)) {
throw new HttpError({
status: res.status,
code: responseBody.error.code,
message: responseBody.error.message,
details: responseBody.error.details,
})
}
throw new HttpError({ status: res.status, code: 'HTTP_ERROR', message: res.statusText })
}
if (isFailure(responseBody)) {
throw new HttpError({
status: 200,
code: responseBody.error.code,
message: responseBody.error.message,
details: responseBody.error.details,
})
}
return responseBody.data
}

20
src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,20 @@
export { fetchApi, HttpError } from './http'
export type { FileItem, FileContent, Settings, UploadedImage, LocalHtmlInfo } from './client'
export {
searchFiles,
fetchFiles,
fetchFileContent,
saveFileContent,
uploadClipboardImage,
deleteFile,
createDirectory,
createFile,
checkPathExists,
renameItem,
runAiTask,
getSettings,
saveSettings,
uploadPdfForParsing,
parseLocalHtml,
} from './client'
export * from './modules'

View File

@@ -0,0 +1,35 @@
interface ModuleApiLike<TEndpoints = Record<string, unknown>> {
moduleId: string
basePath: string
endpoints: TEndpoints
get<T>(endpoint: string, options?: Record<string, unknown>): Promise<T | undefined>
post<T>(endpoint: string, body: unknown, options?: Record<string, unknown>): Promise<T | undefined>
put<T>(endpoint: string, body: unknown, options?: Record<string, unknown>): Promise<T | undefined>
delete<T>(endpoint: string, body?: unknown, options?: Record<string, unknown>): Promise<T | undefined>
postFormData<T>(endpoint: string, formData: FormData): Promise<T | undefined>
}
class ModuleApiRegistryImpl {
private apis: Map<string, ModuleApiLike> = new Map()
register<TEndpoints extends Record<string, unknown>>(api: ModuleApiLike<TEndpoints>): void {
if (this.apis.has(api.moduleId)) {
console.warn(`Module API "${api.moduleId}" is already registered. Overwriting.`)
}
this.apis.set(api.moduleId, api as ModuleApiLike)
}
get<T extends ModuleApiLike>(moduleId: string): T | undefined {
return this.apis.get(moduleId) as T | undefined
}
getAll(): Map<string, ModuleApiLike> {
return new Map(this.apis)
}
has(moduleId: string): boolean {
return this.apis.has(moduleId)
}
}
export const moduleApiRegistry = new ModuleApiRegistryImpl()

View File

@@ -0,0 +1,3 @@
export * from './types'
export { ModuleApiInstance, createModuleApi } from '../createModuleApi'
export * from './ModuleApiRegistry'

View File

@@ -0,0 +1,28 @@
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
export interface EndpointConfig {
path: string
method: HttpMethod
}
export interface RequestOptions {
queryParams?: Record<string, string | number | boolean | undefined>
pathParams?: Record<string, string | number>
body?: unknown
}
export interface ModuleApiConfig<TEndpoints> {
moduleId: string
basePath: string
endpoints: TEndpoints
}
export interface ModuleApi<TEndpoints> {
readonly moduleId: string
readonly config: ModuleApiConfig<TEndpoints>
get<T>(endpoint: keyof TEndpoints, options?: RequestOptions): Promise<T | undefined>
post<T>(endpoint: keyof TEndpoints, body: unknown, options?: RequestOptions): Promise<T | undefined>
put<T>(endpoint: keyof TEndpoints, body: unknown, options?: RequestOptions): Promise<T | undefined>
delete<T>(endpoint: keyof TEndpoints, body?: unknown, options?: RequestOptions): Promise<T | undefined>
postFormData<T>(endpoint: keyof TEndpoints, formData: FormData): Promise<T | undefined>
}

1
src/lib/editor/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './milkdown'

View File

@@ -0,0 +1,397 @@
import { Plugin, PluginKey } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view'
import { Slice, type Node as ProseNode } from 'prosemirror-model'
import { uploadClipboardImage } from '../../api'
const fileToDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.readAsDataURL(file)
})
const urlToDataUrl = async (url: string): Promise<string> => {
const res = await fetch(url)
const blob = await res.blob()
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.readAsDataURL(blob)
})
}
const extractFirstImageDataUrl = (text: string) => {
if (!text) return null
const match = text.match(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+/)
return match ? match[0].replace(/\s/g, '') : null
}
const insertImage = (view: EditorView, src: string) => {
const { state } = view
const imageType = state.schema.nodes.image
if (imageType) {
const node = imageType.create({ src, alt: '' })
view.dispatch(state.tr.replaceSelectionWith(node).scrollIntoView())
return
}
view.dispatch(state.tr.insertText(`![](${src})`).scrollIntoView())
}
const defaultOnError = (message: string) => {
window.alert(message)
}
const looksLikeMarkdown = (text: string) => {
const t = text.trim()
if (!t) return false
if (/^#{1,6}\s/m.test(t)) return true
if (/```|~~~/.test(t)) return true
if (/\*\*[^*\n]+\*\*/.test(t)) return true
if (/\*[^*\n]+\*/.test(t)) return true
if (/!\[[^\]]*]\([^)]+\)/.test(t)) return true
if (/\[[^\]]+]\([^)]+\)/.test(t)) return true
if (/^\s*[-*+]\s+/m.test(t)) return true
if (/^\s*\d+\.\s+/m.test(t)) return true
if (/^\s*>/m.test(t)) return true
if (/\$\$[\s\S]+?\$\$/.test(t)) return true
if (/\$[^$\n]+\$/.test(t)) return true
if (/\\\(|\\\[/.test(t)) return true
return false
}
const normalizeLatexOutsideCode = (text: string) => {
return text
.replace(/\\\[((?:.|\n)*?)\\\]/g, (_m, inner: string) => `$$\n${inner}\n$$`)
.replace(/\\\(((?:.|\n)*?)\\\)/g, (_m, inner: string) => `$${inner}$`)
}
const normalizeLatexInMarkdown = (markdown: string) => {
const fenceRegex = /(```[\s\S]*?```|~~~[\s\S]*?~~~)/g
const fencedParts = markdown.split(fenceRegex)
const processed = fencedParts.map((part) => {
if (part.startsWith('```') || part.startsWith('~~~')) return part
const inlineParts = part.split(/(`+[^`]*`+)/g)
return inlineParts
.map((p) => (p.startsWith('`') ? p : normalizeLatexOutsideCode(p)))
.join('')
})
return processed.join('')
}
const extractTexFromElement = (el: Element): { tex: string; display: boolean } | null => {
if (el.classList.contains('katex')) {
const annotation = el.querySelector('annotation[encoding="application/x-tex"]')
if (annotation && annotation.textContent) {
const tex = annotation.textContent.trim()
const display = Boolean(el.closest('.katex-display') || el.classList.contains('katex-display'))
return { tex, display }
}
}
if (
el.classList.contains('MathJax') ||
el.classList.contains('mjx-chtml') ||
el.tagName.toLowerCase() === 'mjx-container'
) {
const script = el.querySelector('script[type^="math/tex"]')
if (script && script.textContent) {
const tex = script.textContent.trim()
const display = (script.getAttribute('type') || '').includes('mode=display')
return { tex, display }
}
const annotation = el.querySelector('annotation[encoding="application/x-tex"]')
if (annotation && annotation.textContent) {
const tex = annotation.textContent.trim()
const display = el.tagName.toLowerCase() === 'mjx-container' && el.getAttribute('display') === 'true'
return { tex, display }
}
}
if (el.tagName.toLowerCase() === 'annotation') {
const encoding = (el.getAttribute('encoding') || '').toLowerCase()
if (encoding.includes('tex') || encoding.includes('latex')) {
const tex = (el.textContent || '').trim()
if (!tex) return null
const display = Boolean(el.closest('.katex-display'))
return { tex, display }
}
}
if (el.tagName.toLowerCase() === 'script') {
const type = (el.getAttribute('type') || '').toLowerCase()
if (type.includes('math/tex') || type.includes('math/latex')) {
const tex = (el.textContent || '').trim()
if (!tex) return null
const display = type.includes('mode=display')
return { tex, display }
}
}
return null
}
const htmlToMarkdown = (html: string) => {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
let clipboardImageIndex = 0
const placeholders: { token: string; src: string }[] = []
const getText = (node: Node) => (node.textContent || '').replace(/\s+/g, ' ').trim()
const escapeInlineCode = (text: string) => {
const tickCount = Math.max(...Array.from(text.matchAll(/`+/g), (m) => m[0].length), 0) + 1
const ticks = '`'.repeat(tickCount)
return `${ticks}${text}${ticks}`
}
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return (node.nodeValue || '').replace(/\r\n/g, '\n')
if (node.nodeType !== Node.ELEMENT_NODE) return ''
const el = node as Element
const tex = extractTexFromElement(el)
if (tex) {
return tex.display ? `$$\n${tex.tex}\n$$` : `$${tex.tex}$`
}
const tag = el.tagName.toLowerCase()
if (['script', 'style', 'noscript', 'annotation', 'annotation-xml'].includes(tag)) return ''
if (tag === 'br') return '\n'
if (tag === 'p') return `${Array.from(el.childNodes).map(walk).join('')}\n\n`
if (tag === 'div') return `${Array.from(el.childNodes).map(walk).join('')}\n`
if (tag === 'strong' || tag === 'b') return `**${Array.from(el.childNodes).map(walk).join('')}**`
if (tag === 'em' || tag === 'i') return `*${Array.from(el.childNodes).map(walk).join('')}*`
if (tag === 'code' && el.parentElement?.tagName.toLowerCase() !== 'pre') return escapeInlineCode(getText(el))
if (tag === 'pre') {
const codeEl = el.querySelector('code')
const languageClass = codeEl?.getAttribute('class') || ''
const languageMatch = languageClass.match(/language-([a-zA-Z0-9_-]+)/)
const language = languageMatch ? languageMatch[1] : ''
const code = codeEl ? (codeEl.textContent || '') : (el.textContent || '')
return `\n\`\`\`${language}\n${code.replace(/\r\n/g, '\n')}\n\`\`\`\n\n`
}
if (tag === 'a') {
const href = el.getAttribute('href') || ''
const text = Array.from(el.childNodes).map(walk).join('') || href
return href ? `[${text}](${href})` : text
}
if (tag === 'img') {
let src = (el.getAttribute('src') || '').trim()
if (src.startsWith('data:image/')) {
src = src.replace(/\s/g, '')
}
const token = `__CLIPBOARD_IMAGE_${clipboardImageIndex++}__`
placeholders.push({ token, src })
return `![](${token})`
}
if (/^h[1-6]$/.test(tag)) {
const level = Number(tag.slice(1))
const prefix = '#'.repeat(Math.min(Math.max(level, 1), 6))
const text = getText(el)
return `${prefix} ${text}\n\n`
}
if (tag === 'li') return `- ${Array.from(el.childNodes).map(walk).join('').trim()}\n`
if (tag === 'ul' || tag === 'ol') return `\n${Array.from(el.children).map((c) => walk(c)).join('')}\n`
if (tag === 'blockquote') return `\n${getText(el).split('\n').map((l) => `> ${l}`).join('\n')}\n\n`
return Array.from(el.childNodes).map(walk).join('')
}
const markdown = Array.from(doc.body.childNodes).map(walk).join('').replace(/\n{3,}/g, '\n\n').trim()
return { markdown, placeholders }
}
const unique = <T,>(items: T[]) => Array.from(new Set(items))
const extractAllImageDataUrls = (text: string) => {
if (!text) return []
const matches = text.match(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+/g) || []
return matches.map((m) => m.replace(/\s/g, ''))
}
const insertMarkdown = (
view: EditorView,
markdown: string,
parseMarkdown: (markdown: string) => ProseNode | null | undefined,
) => {
try {
const doc = parseMarkdown(markdown)
if (!doc) return false
const slice = new Slice(doc.content, 0, 0)
view.dispatch(view.state.tr.replaceSelection(slice).scrollIntoView())
return true
} catch {
return false
}
}
export const createClipboardImageUploaderPlugin = (options: {
isReadOnly: () => boolean
parseMarkdown: (markdown: string) => ProseNode | null | undefined
onError?: (message: string) => void
}) => {
const onError = options.onError ?? defaultOnError
return new Plugin({
key: new PluginKey('clipboard-image-uploader'),
props: {
handleDOMEvents: {
paste: (view, event) => {
if (options.isReadOnly()) return false
const e = event as ClipboardEvent
const dt = e.clipboardData
if (!dt) return false
const html = dt.getData('text/html')
const text = dt.getData('text/plain')
const imageFiles = Array.from(dt.files).filter((f) => f.type.startsWith('image/'))
const shouldHandle = Boolean(
imageFiles.length > 0 ||
extractFirstImageDataUrl(html) ||
/<img/i.test(html) ||
extractFirstImageDataUrl(text) ||
looksLikeMarkdown(text) ||
/\\\(|\\\[/.test(text) ||
/katex|math\/tex|application\/x-tex/i.test(html),
)
if (!shouldHandle) return false
e.preventDefault()
void (async () => {
try {
let markdown = ''
let placeholders: { token: string; src: string }[] = []
if (html) {
console.log('Detected HTML/Rich Text in clipboard...')
const converted = htmlToMarkdown(html)
markdown = converted.markdown
placeholders = converted.placeholders
}
else if (imageFiles.length > 0) {
console.log('Detected Image Files in clipboard...')
}
else if (text) {
console.log('Detected Plain Text in clipboard...')
markdown = text
}
else {
console.log('Clipboard is empty...')
return
}
markdown = normalizeLatexInMarkdown(markdown)
const dataUrls = unique(extractAllImageDataUrls(markdown))
if (dataUrls.length > 0) {
for (const dataUrl of dataUrls) {
try {
const { name } = await uploadClipboardImage(dataUrl)
markdown = markdown.split(dataUrl).join(name)
} catch (err) {
const message = err instanceof Error ? err.message : '未知错误'
onError(`图片上传失败:${message}`)
}
}
}
let usedFileIndex = 0
if (placeholders.length > 0) {
for (const { token, src } of placeholders) {
if (usedFileIndex < imageFiles.length) {
const file = imageFiles[usedFileIndex]
const dataUrl = await fileToDataUrl(file)
if (dataUrl) {
try {
const { name } = await uploadClipboardImage(dataUrl)
markdown = markdown.split(token).join(name)
usedFileIndex++
continue
} catch (err) {
const message = err instanceof Error ? err.message : '未知错误'
onError(`图片上传失败:${message}`)
}
}
}
if (src.startsWith('http')) {
try {
const dataUrl = await urlToDataUrl(src)
const { name } = await uploadClipboardImage(dataUrl)
markdown = markdown.split(token).join(name)
continue
} catch (err) {
console.warn('Failed to upload remote image:', src, err)
}
}
markdown = markdown.split(token).join(src)
}
}
const remainingFiles = imageFiles.slice(usedFileIndex)
if (remainingFiles.length > 0) {
for (const file of remainingFiles) {
const dataUrl = await fileToDataUrl(file)
if (!dataUrl) continue
try {
const { name } = await uploadClipboardImage(dataUrl)
markdown = markdown ? `${markdown}\n\n![](${name})` : `![](${name})`
} catch (err) {
const message = err instanceof Error ? err.message : '未知错误'
onError(`图片上传失败:${message}`)
}
}
}
const inserted = insertMarkdown(view, markdown, options.parseMarkdown)
if (!inserted && imageFiles.length > 0) {
for (const file of imageFiles) {
const dataUrl = await fileToDataUrl(file)
if (!dataUrl) continue
const { name } = await uploadClipboardImage(dataUrl)
insertImage(view, name)
}
}
} catch (err) {
const message = err instanceof Error ? err.message : '未知错误'
onError(`粘贴处理失败:${message}`)
}
})()
return true
},
drop: (view, event) => {
if (options.isReadOnly()) return false
const e = event as DragEvent
const dt = e.dataTransfer
if (!dt) return false
const imageFiles = Array.from(dt.files).filter((f) => f.type.startsWith('image/'))
if (imageFiles.length === 0) return false
e.preventDefault()
void (async () => {
try {
for (const file of imageFiles) {
const dataUrl = await fileToDataUrl(file)
if (!dataUrl) continue
const { name } = await uploadClipboardImage(dataUrl)
insertImage(view, name)
}
} catch (err) {
const message = err instanceof Error ? err.message : '未知错误'
onError(`图片上传失败:${message}`)
}
})()
return true
},
},
},
})
}

View File

@@ -0,0 +1,149 @@
import type { EditorView } from 'prosemirror-view'
import type { Node as ProseMirrorNode } from 'prosemirror-model'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
export const codeBlockActionButtonRefreshMetaKey = 'code-block-action-button:refresh'
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
try {
const element = document.createElement('textarea')
const previouslyFocusedElement = document.activeElement as HTMLElement | null
element.value = text
element.setAttribute('readonly', '')
element.style.contain = 'strict'
element.style.position = 'absolute'
element.style.left = '-9999px'
element.style.fontSize = '12pt'
const selection = document.getSelection()
const originalRange = selection ? selection.rangeCount > 0 && selection.getRangeAt(0) : null
document.body.appendChild(element)
element.select()
element.selectionStart = 0
element.selectionEnd = text.length
document.execCommand('copy')
document.body.removeChild(element)
if (originalRange && selection) {
selection.removeAllRanges()
selection.addRange(originalRange)
}
previouslyFocusedElement?.focus()
return true
} catch {
return false
}
}
}
const getCodeBlockAtPos = (doc: ProseMirrorNode, pos: number) => {
const $pos = doc.resolve(pos)
for (let depth = $pos.depth; depth >= 0; depth -= 1) {
const node = $pos.node(depth)
if (node.type.name === 'code_block') {
const from = $pos.before(depth)
return { node, from, to: from + node.nodeSize }
}
}
return null
}
const createActionButton = (
view: EditorView,
getPos: () => number | undefined,
isReadOnly: () => boolean
) => {
const button = document.createElement('button')
button.type = 'button'
button.className = 'code-block-action-button'
const action = isReadOnly() ? 'copy' : 'delete'
button.dataset.action = action
button.setAttribute('aria-label', action === 'copy' ? 'Copy code' : 'Delete code block')
button.textContent = action === 'copy' ? '⧉' : '×'
const onPointerDown = async (event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
const pos = getPos()
if (pos == null) return
const { state, dispatch } = view
const block = getCodeBlockAtPos(state.doc, pos)
if (!block) return
if (isReadOnly()) {
const ok = await copyToClipboard(block.node.textContent)
if (!ok) return
button.textContent = '✓'
setTimeout(() => {
const nextAction = isReadOnly() ? 'copy' : 'delete'
button.textContent = nextAction === 'copy' ? '⧉' : '×'
button.dataset.action = nextAction
button.setAttribute(
'aria-label',
nextAction === 'copy' ? 'Copy code' : 'Delete code block'
)
}, 800)
return
}
dispatch(state.tr.delete(block.from, block.to).scrollIntoView())
view.focus()
}
button.addEventListener('pointerdown', onPointerDown)
return button
}
const buildDecorations = (doc: ProseMirrorNode, isReadOnly: () => boolean) => {
const decorations: Decoration[] = []
doc.descendants((node, pos) => {
if (node.type.name !== 'code_block') return true
decorations.push(
Decoration.widget(
pos + 1,
(view, getPos) => createActionButton(view, getPos, isReadOnly),
{ side: -1 }
)
)
return false
})
return DecorationSet.create(doc, decorations)
}
export const createCodeBlockActionButtonPlugin = ({
isReadOnly,
}: {
isReadOnly: () => boolean
}) => {
return new Plugin({
key: new PluginKey('code-block-action-button'),
state: {
init: (_, state) => {
return buildDecorations(state.doc, isReadOnly)
},
apply: (transaction, decorationSet) => {
if (transaction.getMeta(codeBlockActionButtonRefreshMetaKey)) {
return buildDecorations(transaction.doc, isReadOnly)
}
if (transaction.docChanged) return buildDecorations(transaction.doc, isReadOnly)
return decorationSet.map(transaction.mapping, transaction.doc)
},
},
props: {
decorations(state) {
return this.getState(state)
},
},
})
}

View File

@@ -0,0 +1,33 @@
import { linkSchema } from '@milkdown/preset-commonmark'
import type { Ctx } from '@milkdown/ctx'
export const configureExternalLink = (ctx: Ctx) => {
ctx.update(linkSchema.key, (prev) => {
return (ctx) => {
const prevSchema = prev(ctx)
return {
...prevSchema,
toDOM: (node) => {
const { href, title } = node.attrs
const url = href || ''
const isExternal = url.startsWith('http://') || url.startsWith('https://')
const attrs: Record<string, string> = {
href: url,
class: 'link'
}
if (title) {
attrs.title = title
}
if (isExternal) {
attrs.target = '_blank'
attrs.rel = 'noopener noreferrer'
}
return ['a', attrs, 0]
}
}
}
})
}

View File

@@ -0,0 +1,24 @@
import { imageSchema } from '@milkdown/preset-commonmark'
import { resolveImagePath } from '../../utils'
import type { Ctx } from '@milkdown/ctx'
export const configureImagePath = (ctx: Ctx, filePath: string) => {
ctx.update(imageSchema.key, (prev) => {
return (ctx) => {
const prevSchema = prev(ctx)
return {
...prevSchema,
toDOM: (node) => {
const originalSrc = node.attrs.src
const resolvedSrc = resolveImagePath(originalSrc, filePath)
return ['img', {
...node.attrs,
src: resolvedSrc,
'data-original-src': originalSrc,
class: 'image'
}]
}
}
}
})
}

View File

@@ -0,0 +1,6 @@
export { prism, prismConfig } from './prism'
export { createClipboardImageUploaderPlugin } from './clipboardProcessor'
export { registerCommonRefractorLanguages } from './refractorLanguages'
export { codeBlockActionButtonRefreshMetaKey, createCodeBlockActionButtonPlugin } from './codeBlockDeleteButton'
export { configureImagePath } from './imagePathPlugin'
export { configureExternalLink } from './externalLinkPlugin'

View File

@@ -0,0 +1,124 @@
import type { MilkdownPlugin } from '@milkdown/ctx'
import type { Node } from '@milkdown/prose/model'
import type { RootContent, Text } from 'hast'
import type { Refractor } from 'refractor/core'
import { findChildren } from '@milkdown/prose'
import { Plugin, PluginKey } from '@milkdown/prose/state'
import { Decoration, DecorationSet } from '@milkdown/prose/view'
import { $ctx, $prose } from '@milkdown/utils'
import { refractor } from 'refractor'
interface Options {
configureRefractor: (refractor: Refractor) => Refractor | undefined
}
export const prismConfig = $ctx<Options, 'prismConfig'>(
{
configureRefractor: () => undefined,
},
'prismConfig'
)
interface FlattedNode {
text: string
className: string[]
}
const flatNodes = (nodes: RootContent[], className: string[] = []) => {
return nodes.flatMap((node): FlattedNode[] =>
node.type === 'element'
? flatNodes(node.children, [
...className,
...((node.properties?.className as string[]) || []),
])
: [{ text: (node as Text).value, className }]
)
}
const getDecorations = (doc: Node, name: string, currentRefractor: Refractor) => {
const { highlight, listLanguages } = currentRefractor
const allLanguages = listLanguages()
const decorations: Decoration[] = []
findChildren((node) => node.type.name === name)(doc).forEach((block) => {
let from = block.pos + 1
const { language } = block.node.attrs as { language?: string }
if (!language || !allLanguages.includes(language)) {
return
}
const nodes = highlight(block.node.textContent, language)
flatNodes(nodes.children).forEach((node) => {
const to = from + node.text.length
if (node.className.length) {
decorations.push(
Decoration.inline(from, to, {
class: node.className.join(' '),
})
)
}
from = to
})
})
return DecorationSet.create(doc, decorations)
}
export const prismPlugin = $prose((ctx) => {
const { configureRefractor } = ctx.get(prismConfig.key)
const name = 'code_block'
return new Plugin({
key: new PluginKey('MILKDOWN_PRISM'),
state: {
init: (_, { doc }) => {
const configured = configureRefractor(refractor)
return getDecorations(doc, name, configured ?? refractor)
},
apply: (transaction, decorationSet, oldState, state) => {
const isNodeName = state.selection.$head.parent.type.name === name
const isPreviousNodeName =
oldState.selection.$head.parent.type.name === name
const oldNode = findChildren((node) => node.type.name === name)(
oldState.doc
)
const newNode = findChildren((node) => node.type.name === name)(
state.doc
)
const codeBlockChanged =
transaction.docChanged &&
(isNodeName ||
isPreviousNodeName ||
oldNode.length !== newNode.length ||
oldNode[0]?.node.attrs.language !==
newNode[0]?.node.attrs.language ||
transaction.steps.some((step) => {
const s = step as unknown as { from: number; to: number }
return (
s.from !== undefined &&
s.to !== undefined &&
oldNode.some((node) => {
return (
node.pos >= s.from && node.pos + node.node.nodeSize <= s.to
)
})
)
}))
if (codeBlockChanged) return getDecorations(transaction.doc, name, refractor)
return decorationSet.map(transaction.mapping, transaction.doc)
},
},
props: {
decorations(this: Plugin, state) {
return this.getState(state)
},
},
})
})
export const prism: MilkdownPlugin[] = [prismPlugin, prismConfig]

View File

@@ -0,0 +1,44 @@
import type { Refractor } from 'refractor/core'
import bash from 'refractor/bash'
import c from 'refractor/c'
import cpp from 'refractor/cpp'
import csharp from 'refractor/csharp'
import css from 'refractor/css'
import diff from 'refractor/diff'
import go from 'refractor/go'
import java from 'refractor/java'
import javascript from 'refractor/javascript'
import json from 'refractor/json'
import jsx from 'refractor/jsx'
import markdown from 'refractor/markdown'
import markup from 'refractor/markup'
import python from 'refractor/python'
import rust from 'refractor/rust'
import sql from 'refractor/sql'
import tsx from 'refractor/tsx'
import typescript from 'refractor/typescript'
import yaml from 'refractor/yaml'
import hlsl from 'refractor/hlsl'
export const registerCommonRefractorLanguages = (refractor: Refractor) => {
refractor.register(markup)
refractor.register(css)
refractor.register(javascript)
refractor.register(jsx)
refractor.register(typescript)
refractor.register(tsx)
refractor.register(json)
refractor.register(yaml)
refractor.register(bash)
refractor.register(diff)
refractor.register(python)
refractor.register(go)
refractor.register(java)
refractor.register(rust)
refractor.register(sql)
refractor.register(c)
refractor.register(cpp)
refractor.register(csharp)
refractor.register(markdown)
refractor.register(hlsl)
}

11
src/lib/errors.ts Normal file
View File

@@ -0,0 +1,11 @@
export class FileActionError extends Error {
constructor(message: string, public code?: string) {
super(message)
this.name = 'FileActionError'
}
}
export const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) return error.message
return '未知错误'
}

View File

@@ -0,0 +1,9 @@
import { getModuleApi } from '@/lib/module-registry'
import type { ModuleEndpoints } from '@shared/modules/types'
import type { ModuleApiInstance } from '@/lib/api/createModuleApi'
export function useModuleApi<TEndpoints extends ModuleEndpoints = ModuleEndpoints>(
moduleId: string
): ModuleApiInstance<TEndpoints> | undefined {
return getModuleApi<TEndpoints>(moduleId)
}

158
src/lib/module-registry.ts Normal file
View File

@@ -0,0 +1,158 @@
import React from 'react'
import type { LucideIcon } from 'lucide-react'
import type { FileItem } from '@/lib/api'
import type { TOCItem } from '@/lib/utils'
import type { FrontendModuleConfig, InternalModuleConfig, ModuleEndpoints } from '@shared/types/module'
import type { ModuleDefinition } from '@shared/modules/types'
import { moduleApiRegistry } from './api/modules/ModuleApiRegistry'
import { createModuleApi, ModuleApiInstance } from './api/createModuleApi'
export type { FrontendModuleConfig, InternalModuleConfig }
export interface FrontendModule extends InternalModuleConfig {
match: (file: FileItem) => boolean
render: (file: FileItem, isActive: boolean, onTocUpdated?: (toc: TOCItem[]) => void) => React.ReactNode
}
const createFileItem = (name: string, path: string): FileItem => ({
name,
path,
type: 'file',
size: 0,
modified: new Date().toISOString(),
})
class ModuleRegistryImpl {
private modules = new Map<string, InternalModuleConfig>()
register(config: FrontendModuleConfig): void {
if (this.modules.has(config.id)) {
throw new Error(`Module with ID '${config.id}' is already registered`)
}
const tabId = `${config.id}-tab`
const fileItem = createFileItem(config.name, tabId)
const internalConfig: InternalModuleConfig = {
...config,
tabId,
fileItem,
}
this.modules.set(config.id, internalConfig)
console.log(`[ModuleRegistry] Registering module: ${config.id}, basePath: ${config.basePath}, hasEndpoints: ${!!config.endpoints}, endpoints:`, config.endpoints)
if (config.endpoints && config.basePath) {
const apiInstance = createModuleApi({
id: config.id,
name: config.name,
basePath: config.basePath,
order: config.order,
endpoints: config.endpoints,
})
moduleApiRegistry.register(apiInstance)
console.log(`[ModuleRegistry] API registered for: ${config.id}`)
} else {
console.warn(`[ModuleRegistry] Skipping API registration for ${config.id}: endpoints=${!!config.endpoints}, basePath=${config.basePath}`)
}
}
get(id: string): InternalModuleConfig | undefined {
return this.modules.get(id)
}
getAll(): InternalModuleConfig[] {
return Array.from(this.modules.values())
.sort((a, b) => a.order - b.order)
}
has(id: string): boolean {
return this.modules.has(id)
}
getFileItem(id: string): FileItem | undefined {
return this.modules.get(id)?.fileItem
}
getTabId(id: string): string | undefined {
return this.modules.get(id)?.tabId
}
getDefault(): InternalModuleConfig | undefined {
const all = this.getAll()
return all.length > 0 ? all[0] : undefined
}
}
export const registry = new ModuleRegistryImpl()
export function defineModule<
TEndpoints extends ModuleEndpoints
>(config: FrontendModuleConfig<TEndpoints>): FrontendModuleConfig<TEndpoints> {
return config
}
export interface FrontendModuleOptions<
TEndpoints extends ModuleEndpoints = ModuleEndpoints
> {
icon: LucideIcon
component: React.ComponentType
}
export function createFrontendModule<
TEndpoints extends ModuleEndpoints
>(
definition: ModuleDefinition<string, TEndpoints>,
options: FrontendModuleOptions<TEndpoints>
): FrontendModuleConfig<TEndpoints> {
return {
id: definition.id,
name: definition.name,
order: definition.order,
basePath: definition.basePath,
endpoints: definition.endpoints,
icon: options.icon,
component: options.component,
}
}
export function getModuleLegacy(file: FileItem): InternalModuleConfig | undefined {
return registry.getAll().find(m => m.fileItem?.path === file.path)
}
export function matchModule(file: FileItem): InternalModuleConfig | undefined {
return getModuleLegacy(file)
}
export const getAllModules = (): InternalModuleConfig[] => {
return registry.getAll()
}
export const getFileItem = (moduleId: string): FileItem | undefined => {
return registry.getFileItem(moduleId)
}
export const getModuleFileItem = getFileItem
export const getModuleTabId = (moduleId: string): string | undefined => {
return registry.getTabId(moduleId)
}
export const getModule = (moduleId: string): InternalModuleConfig | undefined => {
return registry.get(moduleId)
}
export const getDefaultModule = (): InternalModuleConfig | undefined => {
return registry.getDefault()
}
export function getModuleApi<TEndpoints extends ModuleEndpoints>(
moduleId: string
): ModuleApiInstance<TEndpoints> | undefined {
const api = moduleApiRegistry.get<ModuleApiInstance<TEndpoints>>(moduleId)
if (!api) {
console.error(`[getModuleApi] Failed to get API for module: ${moduleId}. Available APIs:`, Array.from((moduleApiRegistry as any).apis?.keys?.() || []))
}
return api
}

3
src/lib/tabs/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { MarkdownTabPage } from '@/components/tabs/MarkdownTabPage/MarkdownTabPage'
export { matchModule, getModuleLegacy, getAllModules, getFileItem } from '@/lib/module-registry'
export type { FrontendModule, FrontendModuleConfig } from '@/lib/module-registry'

View File

@@ -0,0 +1,297 @@
import { describe, it, expect, vi } from 'vitest'
import {
stripMarkdown,
generateHeadingId,
extractLocalImagePathsFromMarkdown,
} from '../markdown'
vi.mock('../path', async () => {
const actual = await vi.importActual('../path')
return {
...actual,
normalizeNotebookRelPath: vi.fn((src: string, filePath: string) => {
const trimmed = src.trim().replace(/\\/g, '/')
if (!trimmed) return null
const lower = trimmed.toLowerCase()
if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('data:')) return null
if (trimmed.startsWith('/')) {
const parts = trimmed.slice(1).split('/')
return parts.join('/')
}
if (!trimmed.includes('/')) return `images/${trimmed}`
if (trimmed.startsWith('images/')) return trimmed
const dir = filePath.replace(/\\/g, '/').split('/').slice(0, -1).join('/')
const cleanSrc = trimmed.startsWith('./') ? trimmed.slice(2) : trimmed
return dir ? `${dir}/${cleanSrc}` : cleanSrc
}),
}
})
describe('stripMarkdown', () => {
describe('链接处理', () => {
it('应移除链接语法并保留链接文本', () => {
expect(stripMarkdown('[点击这里](https://example.com)')).toBe('点击这里')
})
it('应移除多个链接', () => {
expect(stripMarkdown('[链接1](url1) 和 [链接2](url2)')).toBe('链接1 和 链接2')
})
it('应处理空链接文本', () => {
expect(stripMarkdown('[](url)')).toBe('[](url)')
})
it('应处理只有链接没有文本的情况', () => {
expect(stripMarkdown('[](https://example.com)')).toBe('[](https://example.com)')
})
})
describe('图片处理', () => {
it('应移除图片语法并保留替代文本', () => {
expect(stripMarkdown('![图片描述](image.png)')).toBe('!图片描述')
})
it('应移除没有替代文本的图片语法', () => {
expect(stripMarkdown('![](image.png)')).toBe('')
})
it('应处理多个图片', () => {
expect(stripMarkdown('![图1](img1.png) 和 ![图2](img2.png)')).toBe('!图1 和 !图2')
})
})
describe('粗体处理', () => {
it('应移除 **粗体** 语法', () => {
expect(stripMarkdown('这是 **粗体** 文字')).toBe('这是 粗体 文字')
})
it('应移除 __粗体__ 语法', () => {
expect(stripMarkdown('这是 __粗体__ 文字')).toBe('这是 粗体 文字')
})
it('应处理嵌套格式', () => {
expect(stripMarkdown('**粗体** 和 **另一个粗体**')).toBe('粗体 和 另一个粗体')
})
})
describe('斜体处理', () => {
it('应移除 *斜体* 语法', () => {
expect(stripMarkdown('这是 *斜体* 文字')).toBe('这是 斜体 文字')
})
it('应移除 _斜体_ 语法', () => {
expect(stripMarkdown('这是 _斜体_ 文字')).toBe('这是 斜体 文字')
})
})
describe('代码处理', () => {
it('应移除行内代码语法', () => {
expect(stripMarkdown('使用 `代码` 进行测试')).toBe('使用 代码 进行测试')
})
it('应处理多个行内代码', () => {
expect(stripMarkdown('`code1` 和 `code2`')).toBe('code1 和 code2')
})
})
describe('删除线处理', () => {
it('应移除 ~~删除线~~ 语法', () => {
expect(stripMarkdown('这是 ~~删除线~~ 文字')).toBe('这是 删除线 文字')
})
})
describe('组合格式', () => {
it('应处理多种格式组合', () => {
expect(stripMarkdown('**粗体** 和 *斜体* 和 `代码` 和 ~~删除线~~')).toBe(
'粗体 和 斜体 和 代码 和 删除线'
)
})
it('应处理混合链接和格式', () => {
expect(stripMarkdown('**[粗体链接](url)**')).toBe('粗体链接')
})
})
describe('边界情况', () => {
it('应处理普通文本(无格式)', () => {
expect(stripMarkdown('普通文本')).toBe('普通文本')
})
it('应处理空字符串', () => {
expect(stripMarkdown('')).toBe('')
})
it('应处理只有空白字符的字符串', () => {
expect(stripMarkdown(' ')).toBe('')
})
it('应修整首尾空白', () => {
expect(stripMarkdown(' **粗体** ')).toBe('粗体')
})
})
})
describe('generateHeadingId', () => {
it('应为英文标题生成 slug', () => {
expect(generateHeadingId('Hello World')).toBe('hello-world')
})
it('应为中文标题生成 slug', () => {
expect(generateHeadingId('你好世界')).toBe('你好世界')
})
it('应处理中英混合标题', () => {
expect(generateHeadingId('Hello 你好 World')).toBe('hello-你好-world')
})
it('应处理包含特殊字符的标题', () => {
expect(generateHeadingId('标题 @#$%')).toBe('标题-')
})
it('应处理带格式的标题(先移除格式再生成 slug', () => {
expect(generateHeadingId('**粗体标题**')).toBe('粗体标题')
})
it('应处理带链接的标题', () => {
expect(generateHeadingId('[链接标题](url)')).toBe('链接标题')
})
it('应处理数字开头的标题', () => {
expect(generateHeadingId('2024 年度总结')).toBe('2024-年度总结')
})
it('应处理带空格的标题', () => {
expect(generateHeadingId('Multiple Spaces')).toBe('multiple---spaces')
})
it('应处理下划线', () => {
expect(generateHeadingId('some_underscore')).toBe('some_underscore')
})
it('应生成唯一的 slug相同标题', () => {
const id1 = generateHeadingId('Hello World')
const id2 = generateHeadingId('Hello World')
expect(id1).toBe(id2)
})
it('应处理空字符串', () => {
expect(generateHeadingId('')).toBe('')
})
})
describe('extractLocalImagePathsFromMarkdown', () => {
const filePath = '/notebook/notes/document.md'
describe('提取本地图片路径', () => {
it('应提取简单本地图片路径', () => {
const content = '![图片](image.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toContain('images/image.png')
})
it('应提取带路径的本地图片', () => {
const content = '![图片](images/photo.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toContain('images/photo.png')
})
it('应提取绝对路径图片', () => {
const content = '![图片](/images/logo.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toContain('images/logo.png')
})
it('应提取多个本地图片', () => {
const content = '![图1](img1.png) ![图2](img2.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(2)
expect(result).toContain('images/img1.png')
expect(result).toContain('images/img2.png')
})
it('应处理相对路径图片', () => {
const content = '![图片](./image.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toContain('/notebook/notes/image.png')
})
it('应处理子目录图片', () => {
const content = '![图片](subdir/image.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toContain('/notebook/notes/subdir/image.png')
})
})
describe('忽略 URL 图片', () => {
it('应忽略 http:// 图片', () => {
const content = '![图片](http://example.com/image.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(0)
})
it('应忽略 https:// 图片', () => {
const content = '![图片](https://example.com/image.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(0)
})
it('应忽略 data: 图片', () => {
const content = '![图片](data:image/png;base64,abc123)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(0)
})
it('应混合本地和远程图片,只返回本地', () => {
const content = '![本地](local.png) ![远程](https://example.com/remote.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(1)
expect(result).toContain('images/local.png')
})
})
describe('边界情况', () => {
it('应处理没有图片的情况', () => {
const content = '这是一个普通段落'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(0)
})
it('应处理空字符串', () => {
const result = extractLocalImagePathsFromMarkdown('', filePath)
expect(result).toHaveLength(0)
})
it('应处理空图片 alt 文本', () => {
const content = '![](image.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(1)
})
it('应处理带尖括号的图片路径', () => {
const content = '![图片](<path with spaces.png>)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(1)
})
it('应处理带标题参数的图片', () => {
const content = '![图片](image.png "图片标题")'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(1)
expect(result).toContain('images/image.png')
})
it('应去重重复的图片路径', () => {
const content = '![图1](image.png) ![图2](image.png)'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(1)
})
it('应处理只有空白路径的图片', () => {
const content = '![图片]( )'
const result = extractLocalImagePathsFromMarkdown(content, filePath)
expect(result).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
normalizePosixPath,
normalizeNotebookRelPath,
getFileName,
isMarkdownFile,
getDisplayName,
} from '../path'
vi.mock('../api', () => ({
checkPathExists: vi.fn().mockResolvedValue({ exists: false }),
}))
vi.mock('@shared/utils/path', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
normalizePath: (p: string) => {
const parts = p.replace(/\\/g, '/').split('/')
const stack: string[] = []
for (const part of parts) {
if (!part || part === '.') continue
if (part === '..') {
stack.pop()
continue
}
stack.push(part)
}
return stack.join('/')
},
getFileName: actual.getFileName,
}
})
describe('normalizePosixPath', () => {
it('应将反斜杠转换为正斜杠', () => {
expect(normalizePosixPath('path\\to\\file')).toBe('path/to/file')
})
it('应处理混合路径', () => {
expect(normalizePosixPath('C:\\Users\\test\\file.md')).toBe('C:/Users/test/file.md')
})
it('应正确处理包含 .. 的路径', () => {
expect(normalizePosixPath('path/to/../file')).toBe('path/file')
})
it('应正确处理包含 . 的路径', () => {
expect(normalizePosixPath('path/to/./file')).toBe('path/to/file')
})
it('应处理已经是正斜杠的路径', () => {
expect(normalizePosixPath('path/to/file')).toBe('path/to/file')
})
})
describe('getDisplayName', () => {
it('应移除 .md 扩展名', () => {
expect(getDisplayName('document.md')).toBe('document')
})
it('应保留其他扩展名', () => {
expect(getDisplayName('document.txt')).toBe('document.txt')
})
it('应保留非 .md 扩展名', () => {
expect(getDisplayName('image.png')).toBe('image.png')
})
it('应处理没有扩展名的文件名', () => {
expect(getDisplayName('document')).toBe('document')
})
it('应处理大写 .MD 扩展名', () => {
expect(getDisplayName('document.MD')).toBe('document')
})
})
describe('isMarkdownFile', () => {
it('应正确识别 .md 文件', () => {
expect(isMarkdownFile('document.md')).toBe(true)
})
it('应正确识别大写 .MD 文件', () => {
expect(isMarkdownFile('document.MD')).toBe(true)
})
it('应正确识别小写 .md 文件', () => {
expect(isMarkdownFile('document.md')).toBe(true)
})
it('应正确识别混合大小写 .Md 文件', () => {
expect(isMarkdownFile('document.Md')).toBe(true)
})
it('应拒绝非 md 文件', () => {
expect(isMarkdownFile('document.txt')).toBe(false)
})
it('应拒绝 .mdx 文件', () => {
expect(isMarkdownFile('document.mdx')).toBe(false)
})
it('应拒绝没有扩展名的文件', () => {
expect(isMarkdownFile('document')).toBe(false)
})
it('应拒绝路径中的 md 文件', () => {
expect(isMarkdownFile('/path/to/document.md')).toBe(true)
})
})
describe('normalizeNotebookRelPath', () => {
const filePath = '/notebook/notes/document.md'
it('应忽略 http:// 链接', () => {
expect(normalizeNotebookRelPath('http://example.com/image.png', filePath)).toBeNull()
})
it('应忽略 https:// 链接', () => {
expect(normalizeNotebookRelPath('https://example.com/image.png', filePath)).toBeNull()
})
it('应忽略 data: 链接', () => {
expect(normalizeNotebookRelPath('data:image/png;base64,abc123', filePath)).toBeNull()
})
it('应处理绝对路径(以 / 开头)', () => {
expect(normalizeNotebookRelPath('/images/logo.png', filePath)).toBe('images/logo.png')
})
it('应处理没有路径分隔符的相对路径', () => {
expect(normalizeNotebookRelPath('image.png', filePath)).toBe('images/image.png')
})
it('应处理以 images/ 开头的路径', () => {
expect(normalizeNotebookRelPath('images/photo.png', filePath)).toBe('images/photo.png')
})
it('应处理 ./ 开头的相对路径', () => {
expect(normalizeNotebookRelPath('./image.png', filePath)).toBe('notebook/notes/image.png')
})
it('应处理不带 ./ 的相对路径', () => {
expect(normalizeNotebookRelPath('subdir/image.png', filePath)).toBe('notebook/notes/subdir/image.png')
})
it('应处理空字符串', () => {
expect(normalizeNotebookRelPath('', filePath)).toBeNull()
})
it('应处理仅包含空白的字符串', () => {
expect(normalizeNotebookRelPath(' ', filePath)).toBeNull()
})
it('应处理带反斜杠的路径', () => {
expect(normalizeNotebookRelPath('subdir\\image.png', filePath)).toBe('notebook/notes/subdir/image.png')
})
it('应处理绝对路径中的反斜杠', () => {
expect(normalizeNotebookRelPath('\\images\\logo.png', filePath)).toBe('images/logo.png')
})
describe('getFileName', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('应正确获取文件名', () => {
const result = getFileName('/path/to/document.md')
expect(result).toBe('document.md')
})
it('应处理没有目录的文件名', () => {
const result = getFileName('document.md')
expect(result).toBe('document.md')
})
it('应处理带反斜杠的路径', () => {
const result = getFileName('path\\to\\document.md')
expect(result).toBe('document.md')
})
})
})

14
src/lib/utils/images.ts Normal file
View File

@@ -0,0 +1,14 @@
import { normalizeNotebookRelPath } from './path'
export const resolveImagePath = (src: string, filePath: string) => {
if (!src) return ''
if (src.startsWith('http') || src.startsWith('https') || src.startsWith('data:')) {
return src
}
const resolvedPath = normalizeNotebookRelPath(src, filePath)
if (!resolvedPath) return ''
return `/api/files/raw?path=${encodeURIComponent(resolvedPath)}`
}

5
src/lib/utils/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { normalizePosixPath, normalizeNotebookRelPath, ensurePathDoesNotExist, getFileName, isMarkdownFile, getDisplayName } from './path'
export { resolveImagePath } from './images'
export { stripMarkdown, generateHeadingId, extractLocalImagePathsFromMarkdown } from './markdown'
export type { TOCItem } from './markdown'
export { generatePrintHtml } from './print'

42
src/lib/utils/markdown.ts Normal file
View File

@@ -0,0 +1,42 @@
import GithubSlugger from 'github-slugger'
import { normalizeNotebookRelPath } from './path'
export interface TOCItem {
id: string
text: string
level: number
children: TOCItem[]
}
export const stripMarkdown = (text: string): string => {
return text
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(.*?)\1/g, '$2')
.replace(/`([^`]+)`/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.trim()
}
export const generateHeadingId = (text: string): string => {
const slugger = new GithubSlugger()
return slugger.slug(stripMarkdown(text))
}
export const extractLocalImagePathsFromMarkdown = (content: string, filePath: string) => {
const imageRegex = /!\[[^\]]*]\(([^)]+)\)/g
const paths = new Set<string>()
let match: RegExpExecArray | null
while ((match = imageRegex.exec(content)) !== null) {
const raw = (match[1] || '').trim()
if (!raw) continue
const urlPart = raw.startsWith('<') && raw.endsWith('>')
? raw.slice(1, -1).trim()
: (raw.split(/\s+/)[0] || '').trim()
const normalized = normalizeNotebookRelPath(urlPart, filePath)
if (!normalized) continue
paths.add(normalized)
}
return Array.from(paths)
}

38
src/lib/utils/path.ts Normal file
View File

@@ -0,0 +1,38 @@
import { checkPathExists } from '../api'
import { FileActionError } from '../errors'
import { normalizePath, getFileName as sharedGetFileName } from '@shared/utils/path'
export const normalizePosixPath = (inputPath: string) => {
return normalizePath(inputPath)
}
export const normalizeNotebookRelPath = (src: string, filePath: string) => {
const trimmed = src.trim().replace(/\\/g, '/')
if (!trimmed) return null
const lower = trimmed.toLowerCase()
if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('data:')) return null
if (trimmed.startsWith('/')) return normalizePosixPath(trimmed.slice(1))
if (!trimmed.includes('/')) return normalizePosixPath(`images/${trimmed}`)
if (trimmed.startsWith('images/')) return normalizePosixPath(trimmed)
const dir = filePath.replace(/\\/g, '/').split('/').slice(0, -1).join('/')
const cleanSrc = trimmed.startsWith('./') ? trimmed.slice(2) : trimmed
return normalizePosixPath(dir ? `${dir}/${cleanSrc}` : cleanSrc)
}
export async function ensurePathDoesNotExist(path: string): Promise<void> {
const existsResult = await checkPathExists(path)
if (existsResult.exists) {
throw new FileActionError('当前目录已存在同名文件或文件夹!', 'ALREADY_EXISTS')
}
}
export const getFileName = (path: string) => sharedGetFileName(path)
export const isMarkdownFile = (path: string) => path.toLowerCase().endsWith('.md')
export const getDisplayName = (name: string) => {
const lower = name.toLowerCase()
return lower.endsWith('.md') ? name.slice(0, -3) : name
}

113
src/lib/utils/print.ts Normal file
View File

@@ -0,0 +1,113 @@
export const generatePrintHtml = async (element: HTMLElement, title: string): Promise<string> => {
const styles = Array.from(document.querySelectorAll('style')).map(s => s.outerHTML).join('\n')
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map(l => l.outerHTML).join('\n')
const contentClone = element.cloneNode(true) as HTMLElement
contentClone.removeAttribute('contenteditable')
contentClone.removeAttribute('translate')
return `
<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<meta charset="utf-8">
<base href="${window.location.origin}/">
${styles}
${links}
<style>
body {
margin: 0;
padding: 20px;
background: white !important;
color: black !important;
font-family: system-ui, -apple-system, sans-serif;
}
html, body, .milkdown, .ProseMirror {
height: auto !important;
min-height: auto !important;
max-height: none !important;
overflow: visible !important;
overflow-y: visible !important;
display: block !important;
}
.ProseMirror-trailingBreak { display: none !important; }
@media print {
@page {
margin: 20mm;
size: auto;
}
p, h1, h2, h3, h4, h5, h6, li, code, pre, blockquote, figure {
page-break-inside: avoid;
break-inside: avoid;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
img {
max-width: 100% !important;
height: auto !important;
}
* {
background-color: transparent !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
pre, code {
border: 1px solid #ddd;
white-space: pre-wrap !important;
word-break: break-all;
}
a {
text-decoration: underline;
color: #000;
}
}
.milkdown .ProseMirror {
outline: none !important;
box-shadow: none !important;
padding: 0 !important;
}
</style>
</head>
<body>
<div class="milkdown">
<div class="ProseMirror">
${contentClone.innerHTML}
</div>
</div>
<script>
window.__PRINT_READY__ = false;
const fontReady = document.fonts ? document.fonts.ready : Promise.resolve();
const imagesReady = Promise.all(Array.from(document.images).map(img => {
if (img.complete) return Promise.resolve();
return new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
});
}));
Promise.all([fontReady, imagesReady])
.then(() => {
setTimeout(() => {
window.__PRINT_READY__ = true;
}, 200);
});
</script>
</body>
</html>
`
}