feat: enhance Milkdown editor and file system functionality

- Normalize line endings in Markdown export for DOCX files.
- Improve selection serialization to Markdown with better handling of empty documents.
- Add a new `updateFile` function to the file system for updating file properties.
- Introduce video transcoding capabilities using FFmpeg, supporting various video formats.
- Update AGENTS.md for clearer plugin structure and responsibilities.
- Add scoped styles for TreeNodeItem component to improve UI consistency.
- Implement cross-origin isolation headers in Vite configuration for enhanced security.
- Remove obsolete test_cross.py file.
This commit is contained in:
2026-05-01 20:55:02 +08:00
parent 52ade88840
commit 70152c61b1
43 changed files with 3911 additions and 1373 deletions
+47 -14
View File
@@ -5,6 +5,17 @@ export const DOC_CONTEXT_LIMIT = 32 * 1024
const IMAGE_MD_RE = /!\[[^\]]*]\([^)]+\)/g
const IMAGE_HTML_RE = /<img\b[^>]*>/gi
const HEADER_SEPARATOR = '\n---\n'
const FENCED_DOC_BLOCK_RE = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
const LEGACY_DOC_BLOCK_RE = /<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g
function normalizeMarkdownText(markdown = '') {
return String(markdown || '').replace(/\r\n?/g, '\n')
}
function clipDocContext(content = '', limit = 0) {
if (!limit || content.length <= limit) return content
return `${content.slice(0, limit)}...`
}
export function normalizeDocType(value = '') {
const lower = String(value || '').trim().toLowerCase()
@@ -54,12 +65,9 @@ export function isSupportedDocFile(file) {
}
export function sanitizeDocContent(markdown = '') {
return String(markdown || '')
.replace(/\r\n?/g, '\n')
return normalizeMarkdownText(markdown)
.replace(IMAGE_MD_RE, '')
.replace(IMAGE_HTML_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function quoteMeta(value = '') {
@@ -102,7 +110,7 @@ export function buildDocBlockValue(attrs = {}) {
}
export function parseDocBlockValue(raw = '') {
const normalized = String(raw || '').replace(/\r\n?/g, '\n')
const normalized = normalizeMarkdownText(raw)
const separatorIndex = normalized.indexOf(HEADER_SEPARATOR)
const headerText = separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : ''
const bodyText = separatorIndex >= 0 ? normalized.slice(separatorIndex + HEADER_SEPARATOR.length) : normalized
@@ -150,7 +158,8 @@ export function buildLegacyDocBlock(attrs = {}) {
}
export function parseLegacyDocBlock(raw = '') {
const match = String(raw || '').match(/^<doc_type="([^"]+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)"(?:\s+collapsed="([^"]+)")?>\n?([\s\S]*?)\n?<\/doc_end>$/)
const normalized = normalizeMarkdownText(raw)
const match = normalized.match(/^<doc_type="([^"]+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)"(?:\s+collapsed="([^"]+)")?>\n?([\s\S]*?)\n?<\/doc_end>$/)
if (!match) return null
return {
docType: normalizeDocType(match[1]),
@@ -161,33 +170,57 @@ export function parseLegacyDocBlock(raw = '') {
}
}
export function extractDocBlockContextFromMarkdown(markdown = '', contentLimit = 0) {
const normalized = normalizeMarkdownText(markdown)
const contexts = []
const appendContext = (attrs) => {
if (!attrs) return
const rawContent = String(attrs.content || '')
if (!rawContent.trim()) return
contexts.push(buildDocContextFence({
docType: attrs.docType,
content: clipDocContext(rawContent, contentLimit),
}))
}
normalized.replace(FENCED_DOC_BLOCK_RE, (_full, _prefix, _fence, value) => {
appendContext(parseDocBlockValue(value))
return _full
})
normalized.replace(LEGACY_DOC_BLOCK_RE, (full) => {
appendContext(parseLegacyDocBlock(full))
return full
})
return contexts.join('\n\n')
}
export function transformDocBlockMarkdownForClipboard(markdown = '') {
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
const replacedFence = String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
const normalized = normalizeMarkdownText(markdown)
const replacedFence = normalized.replace(FENCED_DOC_BLOCK_RE, (full, prefix, _fence, value) => {
const attrs = parseDocBlockValue(value)
return `${prefix}${buildDocContextFence(attrs)}`
})
return replacedFence.replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
return replacedFence.replace(LEGACY_DOC_BLOCK_RE, (full) => {
const attrs = parseLegacyDocBlock(full)
return attrs ? buildDocContextFence(attrs) : full
})
}
export function stripDocBlockMarkdown(markdown = '') {
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g
return String(markdown || '').replace(pattern, '$1').replace(/\n{3,}/g, '\n\n').trim()
return normalizeMarkdownText(markdown).replace(FENCED_DOC_BLOCK_RE, '$1')
}
export function transformLegacyDocBlocksForExport(markdown = '') {
return String(markdown || '').replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
return normalizeMarkdownText(markdown).replace(LEGACY_DOC_BLOCK_RE, (full) => {
const attrs = parseLegacyDocBlock(full)
return attrs ? buildDocBlockMarkdown(attrs) : full
})
}
export function transformSpecialDocBlocksToLegacy(markdown = '') {
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
return String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
return normalizeMarkdownText(markdown).replace(FENCED_DOC_BLOCK_RE, (full, prefix, _fence, value) => {
const attrs = parseDocBlockValue(value)
return `${prefix}${buildLegacyDocBlock(attrs)}`
})
+230
View File
@@ -0,0 +1,230 @@
import coreURL from '@ffmpeg/core?url'
import wasmURL from '@ffmpeg/core/wasm?url'
import classWorkerURL from '@ffmpeg/ffmpeg/worker?url'
const VIDEO_EXTENSIONS = new Set([
'mp4',
'webm',
'ogv',
'ogg',
'mov',
'm4v',
'avi',
'mkv',
'flv',
'wmv',
'3gp',
'm2ts',
'mts',
'ts'
])
const FORCED_TRANSCODE_EXTENSIONS = new Set([
'3gp',
'avi',
'flv',
'm2ts',
'mkv',
'mts',
'ts',
'wmv'
])
const VIDEO_MIME_BY_EXTENSION = {
m4v: 'video/x-m4v',
mov: 'video/quicktime',
mp4: 'video/mp4',
ogg: 'video/ogg',
ogv: 'video/ogg',
webm: 'video/webm'
}
let ffmpegInstancePromise = null
let progressHandler = null
let transcodeQueue = Promise.resolve()
function getFileExtension(name = '') {
const parts = String(name).split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
}
function getNodeMimeType(node) {
return String(node?.mimeType || '').toLowerCase()
}
function formatFfmpegTime(seconds = 0) {
const totalSeconds = Math.max(0, Number(seconds) || 0)
return totalSeconds.toFixed(3)
}
function createVideoProbe() {
if (typeof document === 'undefined') return null
return document.createElement('video')
}
function toAbsoluteURL(url) {
if (!url || typeof location === 'undefined') {
return url
}
return new URL(url, location.href).href
}
function getLoadConfigs() {
return [{
classWorkerURL: toAbsoluteURL(classWorkerURL),
coreURL: toAbsoluteURL(coreURL),
wasmURL: toAbsoluteURL(wasmURL)
}]
}
async function loadFFmpegRuntime(FFmpeg, fetchFile) {
let lastError = null
for (const loadConfig of getLoadConfigs()) {
const ffmpeg = new FFmpeg()
ffmpeg.on('progress', ({ progress }) => {
if (typeof progressHandler === 'function') {
progressHandler(Math.max(0, Math.min(1, progress || 0)))
}
})
try {
await ffmpeg.load(loadConfig)
return { ffmpeg, fetchFile }
} catch (error) {
lastError = error
}
}
throw lastError || new Error('FFmpeg 初始化失败')
}
async function getFFmpegInstance() {
if (!ffmpegInstancePromise) {
ffmpegInstancePromise = Promise.all([
import('@ffmpeg/ffmpeg'),
import('@ffmpeg/util')
]).then(([{ FFmpeg }, { fetchFile }]) => loadFFmpegRuntime(FFmpeg, fetchFile))
}
return ffmpegInstancePromise
}
function buildMp4Command(inputName, outputName, options = {}) {
const startTime = Math.max(0, Number(options.startTime) || 0)
const endTime = Number.isFinite(options.endTime) ? Number(options.endTime) : null
const duration = endTime === null ? null : Math.max(0.05, endTime - startTime)
const args = []
if (startTime > 0) {
args.push('-ss', formatFfmpegTime(startTime))
}
args.push('-i', inputName)
if (duration !== null) {
args.push('-t', formatFfmpegTime(duration))
}
if (options.preferEncoding !== false) {
args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p')
if (options.muteAudio) {
args.push('-an')
} else {
args.push('-c:a', 'aac')
}
} else if (options.muteAudio) {
args.push('-an')
}
args.push('-movflags', '+faststart', '-y', outputName)
return args
}
async function executePreferredMp4Command(ffmpeg, inputName, outputName, options = {}) {
try {
await ffmpeg.exec(buildMp4Command(inputName, outputName, {
...options,
preferEncoding: true
}))
} catch {
await ffmpeg.exec(buildMp4Command(inputName, outputName, {
...options,
preferEncoding: false
}))
}
}
async function runMp4Job(blob, fileName, options = {}) {
const { ffmpeg, fetchFile } = await getFFmpegInstance()
const ext = getFileExtension(fileName) || 'bin'
const inputName = `input-${Date.now()}.${ext}`
const outputName = `output-${Date.now()}.mp4`
progressHandler = typeof options.onProgress === 'function' ? options.onProgress : null
try {
await ffmpeg.writeFile(inputName, await fetchFile(blob))
await executePreferredMp4Command(ffmpeg, inputName, outputName, options)
const data = await ffmpeg.readFile(outputName)
return new Blob([data.buffer], { type: 'video/mp4' })
} finally {
progressHandler = null
await ffmpeg.deleteFile?.(inputName)
await ffmpeg.deleteFile?.(outputName)
}
}
export function isVideoFile(node) {
const ext = getFileExtension(node?.name)
const mime = getNodeMimeType(node)
return mime.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)
}
export function canPlayVideoNatively(node) {
if (!isVideoFile(node)) return false
const ext = getFileExtension(node?.name)
if (FORCED_TRANSCODE_EXTENSIONS.has(ext)) return false
const mime = getNodeMimeType(node) || VIDEO_MIME_BY_EXTENSION[ext] || ''
if (!mime) return ['mp4', 'ogv', 'ogg', 'webm'].includes(ext)
const video = createVideoProbe()
if (!video) return ['mp4', 'ogv', 'ogg', 'webm'].includes(ext)
return video.canPlayType(mime) !== ''
}
export async function transcodeVideoToMp4(blob, fileName, options = {}) {
const run = async () => {
try {
return await runMp4Job(blob, fileName, options)
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: '浏览器内视频转换失败'
throw new Error(message)
}
}
const result = transcodeQueue.then(run, run)
transcodeQueue = result.then(() => undefined, () => undefined)
return result
}
export async function editVideoBlob(blob, fileName, options = {}) {
const run = async () => {
try {
return await runMp4Job(blob, fileName, options)
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: '浏览器内视频编辑失败'
throw new Error(message)
}
}
const result = transcodeQueue.then(run, run)
transcodeQueue = result.then(() => undefined, () => undefined)
return result
}