Initial commit
This commit is contained in:
122
src/lib/api/client.ts
Normal file
122
src/lib/api/client.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
104
src/lib/api/createModuleApi.ts
Normal file
104
src/lib/api/createModuleApi.ts
Normal 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
71
src/lib/api/http.ts
Normal 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
20
src/lib/api/index.ts
Normal 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'
|
||||
35
src/lib/api/modules/ModuleApiRegistry.ts
Normal file
35
src/lib/api/modules/ModuleApiRegistry.ts
Normal 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()
|
||||
3
src/lib/api/modules/index.ts
Normal file
3
src/lib/api/modules/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './types'
|
||||
export { ModuleApiInstance, createModuleApi } from '../createModuleApi'
|
||||
export * from './ModuleApiRegistry'
|
||||
28
src/lib/api/modules/types.ts
Normal file
28
src/lib/api/modules/types.ts
Normal 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
1
src/lib/editor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './milkdown'
|
||||
397
src/lib/editor/milkdown/clipboardProcessor.ts
Normal file
397
src/lib/editor/milkdown/clipboardProcessor.ts
Normal 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(``).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 ``
|
||||
}
|
||||
|
||||
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` : ``
|
||||
} 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
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
149
src/lib/editor/milkdown/codeBlockDeleteButton.ts
Normal file
149
src/lib/editor/milkdown/codeBlockDeleteButton.ts
Normal 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)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
33
src/lib/editor/milkdown/externalLinkPlugin.ts
Normal file
33
src/lib/editor/milkdown/externalLinkPlugin.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
24
src/lib/editor/milkdown/imagePathPlugin.ts
Normal file
24
src/lib/editor/milkdown/imagePathPlugin.ts
Normal 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'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
6
src/lib/editor/milkdown/index.ts
Normal file
6
src/lib/editor/milkdown/index.ts
Normal 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'
|
||||
124
src/lib/editor/milkdown/prism.ts
Normal file
124
src/lib/editor/milkdown/prism.ts
Normal 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]
|
||||
44
src/lib/editor/milkdown/refractorLanguages.ts
Normal file
44
src/lib/editor/milkdown/refractorLanguages.ts
Normal 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
11
src/lib/errors.ts
Normal 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 '未知错误'
|
||||
}
|
||||
9
src/lib/hooks/useModuleApi.ts
Normal file
9
src/lib/hooks/useModuleApi.ts
Normal 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
158
src/lib/module-registry.ts
Normal 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
3
src/lib/tabs/index.ts
Normal 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'
|
||||
297
src/lib/utils/__tests__/markdown.test.ts
Normal file
297
src/lib/utils/__tests__/markdown.test.ts
Normal 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('')).toBe('!图片描述')
|
||||
})
|
||||
|
||||
it('应移除没有替代文本的图片语法', () => {
|
||||
expect(stripMarkdown('')).toBe('')
|
||||
})
|
||||
|
||||
it('应处理多个图片', () => {
|
||||
expect(stripMarkdown(' 和 ')).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 = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toContain('images/image.png')
|
||||
})
|
||||
|
||||
it('应提取带路径的本地图片', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toContain('images/photo.png')
|
||||
})
|
||||
|
||||
it('应提取绝对路径图片', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toContain('images/logo.png')
|
||||
})
|
||||
|
||||
it('应提取多个本地图片', () => {
|
||||
const content = ' '
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toContain('images/img1.png')
|
||||
expect(result).toContain('images/img2.png')
|
||||
})
|
||||
|
||||
it('应处理相对路径图片', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toContain('/notebook/notes/image.png')
|
||||
})
|
||||
|
||||
it('应处理子目录图片', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toContain('/notebook/notes/subdir/image.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('忽略 URL 图片', () => {
|
||||
it('应忽略 http:// 图片', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('应忽略 https:// 图片', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('应忽略 data: 图片', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('应混合本地和远程图片,只返回本地', () => {
|
||||
const content = ' '
|
||||
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 = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('应处理带尖括号的图片路径', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('应处理带标题参数的图片', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result).toContain('images/image.png')
|
||||
})
|
||||
|
||||
it('应去重重复的图片路径', () => {
|
||||
const content = ' '
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('应处理只有空白路径的图片', () => {
|
||||
const content = ''
|
||||
const result = extractLocalImagePathsFromMarkdown(content, filePath)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
184
src/lib/utils/__tests__/path.test.ts
Normal file
184
src/lib/utils/__tests__/path.test.ts
Normal 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
14
src/lib/utils/images.ts
Normal 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
5
src/lib/utils/index.ts
Normal 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
42
src/lib/utils/markdown.ts
Normal 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
38
src/lib/utils/path.ts
Normal 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
113
src/lib/utils/print.ts
Normal 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>
|
||||
`
|
||||
}
|
||||
Reference in New Issue
Block a user