Files
llm-in-text/src/components/MilkdownEditor.vue
ydy0615 70152c61b1 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.
2026-05-01 20:55:02 +08:00

1817 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="editor-container">
<div ref="root" class="milkdown-editor"></div>
<div class="history-buttons">
<button
type="button"
class="history-btn"
:disabled="!canUndo"
:aria-label="undoLabel"
:title="undoLabel"
@click="handleUndo"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 14 4 9l5-5"/>
<path d="M4 9h11a4 4 0 1 1 0 8h-1"/>
</svg>
</button>
<button
type="button"
class="history-btn"
:disabled="!canRedo"
:aria-label="redoLabel"
:title="redoLabel"
@click="handleRedo"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m15 14 5-5-5-5"/>
<path d="M20 9H9a4 4 0 1 0 0 8h1"/>
</svg>
</button>
</div>
<div class="action-buttons">
<button
type="button"
class="action-btn"
:class="{ 'force-disabled': isDocUploadDisabled }"
:aria-label="t('upload')"
:title="uploadButtonTitle"
@click="triggerUpload"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<rect x="7" y="11" width="8" height="6" rx="1" stroke-width="1.5"/>
<circle cx="9" cy="13" r="0.8" fill="currentColor"/>
<path d="M7 16l2-2 2 2" stroke-width="1.5"/>
</svg>
<span class="btn-tooltip">{{ t('upload') }}</span>
</button>
<input type="file" ref="uploadInputRef" @change="handleUpload" :accept="acceptAll" multiple style="display:none">
<button
type="button"
class="action-btn"
:aria-label="t('importMd')"
:title="t('importMd')"
@click="triggerImportMd"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span class="btn-tooltip">{{ t('importMd') }}</span>
</button>
<input type="file" ref="mdInputRef" @change="handleImportMd" accept=".md,text/markdown,text/x-markdown" style="display:none">
<div class="export-btn-wrapper">
<button
type="button"
class="action-btn"
:aria-label="t('exportMd')"
:title="t('exportMd')"
@click="toggleExportDropdown"
@contextmenu.prevent="toggleExportDropdown"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
<path d="m19 9-4 4-4-4"/>
</svg>
<span class="btn-tooltip">{{ t('exportMd') }}</span>
</button>
<div v-if="showExportDropdown" class="export-dropdown">
<button type="button" @click="() => { exportMarkdown(); showExportDropdown = false; }">{{ t('exportMd') }}</button>
<button type="button" @click="() => { exportDocx(); showExportDropdown = false; }">{{ t('exportDocx') }}</button>
<button type="button" @click="() => { exportPdf(); showExportDropdown = false; }">{{ t('exportPdf') }}</button>
</div>
</div>
<button
type="button"
class="action-btn ai-toggle"
:class="{ 'ai-disabled': !aiEnabled, 'force-disabled': isOverLimit }"
@click="toggleAI"
:disabled="isOverLimit"
:aria-label="aiButtonLabel"
:title="aiButtonLabel"
>
<svg v-if="aiEnabled && !isOverLimit" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3l1.9 3.9L18 9l-4.1 2.1L12 15l-1.9-3.9L6 9l4.1-2.1L12 3z"/>
<path d="M5 14l.9 1.8L8 16.7l-2.1.9L5 20l-.9-2.4L2 16.7l2.1-.9L5 14z"/>
<path d="M19 14l.6 1.2 1.4.6-1.4.6L19 18l-.6-1.6-1.4-.6 1.4-.6L19 14z"/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="9"/>
<line x1="5" y1="5" x2="19" y2="19"/>
</svg>
<span class="btn-tooltip">{{ aiButtonLabel }}</span>
</button>
<div
class="size-indicator"
:class="{ 'over-limit': isOverLimit }"
@mouseenter="showSizeTooltip = true"
@mouseleave="showSizeTooltip = false"
>
<svg
class="warning-icon"
:class="{ 'warning-icon--visible': isOverLimit }"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
{{ sizeInKB }} KB
<Transition name="tooltip-fade">
<div v-if="showSizeTooltip && isOverLimit" class="size-tooltip">
<strong>文档超过32KB限制</strong>
<span>AI补全功能已暂停建议精简内容或分段处理</span>
</div>
</Transition>
</div>
</div>
</div>
<div v-if="uploadProgress" class="upload-progress-overlay">
<div class="upload-progress-dialog">
<div class="spinner"></div>
<p>{{ t('uploading') || '正在上传文件' }}</p>
<p class="progress-text">
{{ uploadProgress.current }} / {{ uploadProgress.total }}
</p>
<p class="filename">{{ uploadProgress.filename }}</p>
</div>
</div>
<TTSMenu
:visible="ttsMenuVisible"
:x="ttsMenuX"
:y="ttsMenuY"
:loading="ttsLoading"
@speak="handleTTSSpeak"
/>
<TTSPlayer
:visible="ttsPlayerVisible"
:audio-base64="ttsAudioBase64"
:format="ttsFormat"
:duration-ms="ttsDuration"
@close="closeTTSPlayer"
/>
</template>
<script setup>
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
import { replaceAll, replaceRange } from '@milkdown/kit/utils'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state'
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { docBlockNode, docBlockRemark, docBlockView } from '../plugins/docBlockPlugin'
import { mermaidRenderPreview, refreshMermaidPreviews, codeBlockConfig } from '../plugins/mermaidPlugin'
import { fetchSuggestion, fetchTTS } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme.js'
import { OCR_URL, EXPORT_PDF_URL } from '../utils/config.js'
import TTSMenu from './TTSMenu.vue'
import TTSPlayer from './TTSPlayer.vue'
import { convertFileToMarkdown } from '../utils/convert.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
import { DOC_BLOCK_NODE_TYPE, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js'
const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
const { isDark } = useTheme()
const t = (key) => settings.t[key]
const initialMarkdown = computed(() => settings.initialMarkdown)
const root = ref(null)
const uploadInputRef = ref(null)
const mdInputRef = ref(null)
const aiEnabled = ref(true)
const contentSize = ref(0)
const showExportDropdown = ref(false)
const showSizeTooltip = ref(false)
const canUndo = ref(false)
const canRedo = ref(false)
const isDocUploadDisabled = ref(false)
const uploadProgress = ref(null)
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo')
const redoLabel = computed(() => t('redo') || 'Redo')
const API_KEY = 'your-secret-key-here'
const ttsMenuVisible = ref(false)
const ttsMenuX = ref(0)
const ttsMenuY = ref(0)
const ttsLoading = ref(false)
const ttsPlayerVisible = ref(false)
const ttsAudioBase64 = ref('')
const ttsFormat = ref('wav')
const ttsDuration = ref(0)
const savedSelection = ref(null)
const selectedText = ref('')
let ttsMouseUpHandler = null
let ttsClickOutsideHandler = null
const aiButtonLabel = computed(() => {
if (isOverLimit.value) return t('docTooLarge')
return aiEnabled.value ? t('disableAI') : t('enableAI')
})
const uploadButtonTitle = computed(() => {
if (isDocUploadDisabled.value) return t('uploadDocInBlockWarning') || '当前光标位置不能插入文件'
return t('upload')
})
const acceptAll = computed(() => {
const types = [
'.txt', '.json', '.toml', '.yaml', '.yml',
'.docx', '.pptx', '.pdf',
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.heic', '.heif', '.avif',
'text/plain', 'application/json',
'text/yaml', 'text/x-yaml', 'application/x-yaml',
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'image/*'
]
return types.join(',')
})
let crepe = null
let markdownSyncTimer = null
let editorCopyHandler = null
const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
const CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i
const TEXT_EXT_RE = /\.(txt|json|toml|ya?ml)$/i
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json', 'text/yaml', 'text/x-yaml', 'application/x-yaml'])
const CONVERT_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/pdf',
])
let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)
const normalizeTrailingWhitespace = (value) => (value || '').replace(/\s+$/, '')
const padTimePart = (value) => String(value).padStart(2, '0')
const createExportName = () => {
const now = new Date()
const datePart = `${now.getFullYear()}${padTimePart(now.getMonth() + 1)}${padTimePart(now.getDate())}`
const timePart = `${padTimePart(now.getHours())}${padTimePart(now.getMinutes())}${padTimePart(now.getSeconds())}`
return `save${datePart}${timePart}`
}
const buildDocxBlob = async (markdown) => {
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
const children = []
const normalizedMarkdown = String(markdown || '').replace(/\r\n?/g, '\n')
for (const line of normalizedMarkdown.split('\n')) {
if (line.startsWith('# ')) {
children.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
continue
}
if (line.startsWith('## ')) {
children.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
continue
}
if (line.startsWith('### ')) {
children.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
continue
}
if (line.startsWith('---')) {
children.push(new Paragraph({ text: '----------' }))
continue
}
children.push(line.trim() === '' ? new Paragraph({}) : new Paragraph({ text: line }))
}
return Packer.toBlob(new Document({ sections: [{ properties: {}, children }] }))
}
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
const getExportMarkdown = async () => {
if (!crepe) {
throw new Error('编辑器未初始化,请稍后重试')
}
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
clearCurrentSuggestion(view)
})
const markdown = await crepe.getMarkdown()
return transformLegacyDocBlocksForExport(markdown)
}
const syncInitialMarkdown = async (nextValue) => {
if (!crepe) {
lastInitialMarkdown = transformSpecialDocBlocksToLegacy(nextValue)
return
}
const normalizedNextValue = transformSpecialDocBlocksToLegacy(nextValue)
try {
const current = await crepe.getMarkdown()
const normalizedCurrent = normalizeTrailingWhitespace(current)
const normalizedLast = normalizeTrailingWhitespace(lastInitialMarkdown)
if (!normalizedCurrent || normalizedCurrent === normalizedLast) {
crepe.editor.action(replaceAll(normalizedNextValue))
}
} catch {
// Ignore sync errors
} finally {
lastInitialMarkdown = normalizedNextValue
}
}
watch(
() => settings.initialMarkdown,
(nextValue) => {
void syncInitialMarkdown(nextValue)
}
)
watch(
() => isDark.value,
() => {
refreshMermaidPreviews()
}
)
const revokeObjectUrl = (url) => {
if (!objectUrls.has(url)) return
URL.revokeObjectURL(url)
objectUrls.delete(url)
clearOcrCache(url)
}
const collectImageObjectUrls = (doc) => {
const activeUrls = new Set()
doc.descendants((node) => {
const src = typeof node.attrs?.src === 'string' ? node.attrs.src : ''
if (
IMAGE_NODE_TYPES.has(node.type?.name) &&
src.startsWith('blob:')
) {
activeUrls.add(src)
}
})
return activeUrls
}
const syncObjectUrls = (doc) => {
const activeUrls = collectImageObjectUrls(doc)
for (const url of Array.from(objectUrls)) {
if (!activeUrls.has(url)) {
revokeObjectUrl(url)
}
}
}
const refreshSizeAndLimit = (ctx) => {
const view = ctx.get(editorViewCtx)
const { size, overLimit } = checkSizeLimit(view)
contentSize.value = size
if (overLimit && aiEnabled.value) {
aiEnabled.value = false
setCopilotEnabled(view, false)
}
}
const scheduleMarkdownSync = () => {
if (!crepe) return
if (markdownSyncTimer) {
clearTimeout(markdownSyncTimer)
markdownSyncTimer = null
}
markdownSyncTimer = setTimeout(async () => {
markdownSyncTimer = null
if (!crepe) return
try {
let hasGhostSuggestion = false
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const state = COPILOT_PLUGIN_KEY.getState(view.state)
hasGhostSuggestion = Boolean(state?.suggestion && state.from < state.to)
})
if (hasGhostSuggestion) return
const markdown = await crepe.getMarkdown()
emit('update:markdown', markdown)
} catch {
// Ignore sync errors
}
}, 120)
}
const clearCurrentSuggestion = (view) => {
clearGhostSuggestion(view)
}
const clearCurrentGhost = () => {
if (!crepe) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
clearGhostSuggestion(view)
})
}
const updateHistoryState = (view) => {
canUndo.value = undoDepth(view.state) > 0
canRedo.value = redoDepth(view.state) > 0
}
const serializeSelectionToMarkdown = (view, from, to) => {
const state = view.state
const slice = state.doc.slice(from, to)
const doc = state.schema.topNodeType.createAndFill(undefined, slice.content)
if (!doc) return state.doc.textBetween(from, to, '\n\n', '\n')
return crepe?.editor?.action((ctx) => {
const serializer = ctx.get(serializerCtx)
return serializer(doc)
}) || state.doc.textBetween(from, to, '\n\n', '\n')
}
const selectionIncludesDocBlock = (state) => {
const { from, to } = state.selection
let hasDocBlock = false
state.doc.nodesBetween(from, to, (node) => {
if (node.type?.name === DOC_BLOCK_NODE_TYPE) {
hasDocBlock = true
return false
}
return true
})
return hasDocBlock
}
const getCursorContext = (view) => {
const { $from } = view.state.selection
let inDocBlock = false
let fenceLanguage = ''
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth)
const typeName = node.type?.name || ''
if (typeName === DOC_BLOCK_NODE_TYPE) {
inDocBlock = true
break
}
if (typeName === 'code_block' || typeName === 'codeBlock' || typeName === 'code_fence' || typeName === 'fence') {
fenceLanguage = String(node.attrs?.language || node.attrs?.lang || node.attrs?.info || '').trim().toLowerCase()
break
}
}
const disabledByFence = fenceLanguage === 'mermaid' || fenceLanguage === 'tex' || fenceLanguage === 'latex' || fenceLanguage === 'katex'
return {
disabled: inDocBlock || disabledByFence,
inDocBlock,
fenceLanguage,
}
}
const refreshDocUploadState = (view) => {
isDocUploadDisabled.value = getCursorContext(view).disabled
}
const SKIP_TTS_TYPES = new Set([
'code_block', 'codeBlock', 'code_fence', 'fence',
'code_inline', 'codeInline',
'math_inline', 'math_block', 'math_display', 'mathInline', 'mathBlock',
'mermaid', 'mermaidBlock',
])
const extractSelectionText = (view, from, to) => {
const { doc } = view.state
const parts = []
const pushBlockSeparator = () => {
if (parts.length === 0) return
const lastPart = parts[parts.length - 1]
if (typeof lastPart === 'string' && lastPart.endsWith('\n')) return
parts.push('\n')
}
doc.nodesBetween(from, to, (node, pos) => {
if (SKIP_TTS_TYPES.has(node.type.name)) {
return false
}
if (node.isTextblock && pos >= from) {
pushBlockSeparator()
}
if (node.type.name === DOC_BLOCK_NODE_TYPE) {
pushBlockSeparator()
if (node.attrs.content) {
parts.push(node.attrs.content)
}
return false
}
if (node.isText && node.text) {
const nodeStart = pos
const nodeEnd = pos + node.nodeSize
const overlapStart = Math.max(nodeStart, from)
const overlapEnd = Math.min(nodeEnd, to)
if (overlapEnd > overlapStart) {
const textStart = overlapStart - pos
const textLen = overlapEnd - overlapStart
parts.push(node.text.slice(textStart, textStart + textLen))
}
}
return true
})
return parts.join('').trim()
}
const showTTSMenu = (event) => {
if (!crepe) return
const view = crepe.editor.action((ctx) => ctx.get(editorViewCtx))
if (!view) return
const { from, to } = view.state.selection
if (from === to) {
ttsMenuVisible.value = false
return
}
if (isOverLimit.value) {
ttsMenuVisible.value = false
return
}
const text = extractSelectionText(view, from, to)
if (!text) {
ttsMenuVisible.value = false
return
}
selectedText.value = text
const domSelection = window.getSelection()
if (domSelection.rangeCount > 0) {
const range = domSelection.getRangeAt(0)
const rect = range.getBoundingClientRect()
ttsMenuX.value = rect.left + rect.width / 2
ttsMenuY.value = rect.top - 8
ttsMenuVisible.value = true
}
}
const handleTTSSpeak = async () => {
if (!selectedText.value || !crepe) return
const view = crepe.editor.action((ctx) => ctx.get(editorViewCtx))
const { from } = view.state.selection
savedSelection.value = { from }
const tr = view.state.tr.setSelection(Selection.near(view.state.doc.resolve(from)))
view.dispatch(tr)
ttsMenuVisible.value = false
ttsLoading.value = true
try {
const response = await fetchTTS(selectedText.value, settings.ttsInstruct || '')
ttsAudioBase64.value = response.audio_base64
ttsFormat.value = response.format
ttsDuration.value = response.duration_ms
ttsPlayerVisible.value = true
} catch (error) {
console.error('TTS 失败:', error)
alert('语音生成失败')
} finally {
ttsLoading.value = false
}
}
const closeTTSPlayer = () => {
ttsPlayerVisible.value = false
ttsAudioBase64.value = ''
ttsFormat.value = 'wav'
ttsDuration.value = 0
savedSelection.value = null
selectedText.value = ''
}
const runHistoryCommand = (command) => {
if (!crepe) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
interruptCopilot(view)
clearCurrentSuggestion(view)
command(view.state, (tr) => view.dispatch(tr), view)
updateHistoryState(view)
view.focus()
})
}
const handleUndo = () => {
runHistoryCommand(undo)
}
const handleRedo = () => {
runHistoryCommand(redo)
}
const isMarkdownFile = (file) => {
if (!file) return false
const name = (file.name || '').toLowerCase()
const type = (file.type || '').toLowerCase()
return MARKDOWN_EXT_RE.test(name) || type === 'text/markdown' || type === 'text/x-markdown'
}
const isImageFile = (file) => {
if (!file) return false
const name = (file.name || '').toLowerCase()
const type = (file.type || '').toLowerCase()
return type.startsWith('image/') || IMAGE_EXT_RE.test(name)
}
const isTextFile = (file) => {
if (!file) return false
const name = (file.name || '').toLowerCase()
const type = (file.type || '').toLowerCase()
return TEXT_EXT_RE.test(name) || TEXT_MIME_TYPES.has(type)
}
const isConvertibleFile = (file) => {
if (!file) return false
const name = (file.name || '').toLowerCase()
const type = (file.type || '').toLowerCase()
return CONVERT_EXT_RE.test(name) || CONVERT_MIME_TYPES.has(type)
}
const warnUnsupportedUploadType = () => {
alert(t('uploadMdTypeWarning') || 'Only Markdown (.md) files and image files are supported.')
}
const warnUnsupportedInsertType = () => {
alert(t('uploadFileTypeWarning') || 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.')
}
const warnUploadError = (message = '') => {
const base = t('uploadFileError') || 'File upload failed.'
alert(message ? `${base}\n${message}` : base)
}
const warnConvertError = (message = '') => {
const base = t('uploadConvertError') || 'File conversion failed.'
alert(message ? `${base}\n${message}` : base)
}
const warnImageTooLarge = () => {
const limitMB = Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)
alert(t('imgTooLarge') || `Image too large. Max ${limitMB}MB.`)
}
const performOCR = async (file, cacheKey, imageHash = '') => {
if (!aiEnabled.value) return
const reader = new FileReader()
reader.onload = async () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : ''
const splitIndex = dataUrl.indexOf(',')
if (splitIndex === -1) return
const base64 = dataUrl.slice(splitIndex + 1)
try {
const res = await fetch(OCR_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your-secret-key-here'
},
body: JSON.stringify({
image: base64,
filename: file.name,
language: 'auto'
})
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
const data = await res.json()
if (data.text) {
setOcrCache(cacheKey, data.text)
setOcrCache(file.name, data.text)
if (imageHash) {
setOcrByHash(imageHash, data.text)
}
}
} catch {
// OCR error, ignore
}
}
reader.readAsDataURL(file)
}
const prepareImageFile = async (file) => {
if (!isImageFile(file)) {
warnUnsupportedUploadType()
return null
}
if (file.size > IMAGE_SIZE_LIMIT) {
warnImageTooLarge()
return null
}
const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl)
const arrayBuffer = await file.arrayBuffer()
const imageBytes = new Uint8Array(arrayBuffer)
const imageHash = await calculateImageHash(imageBytes)
const existingOcr = getOcrByHash(imageHash)
if (!existingOcr) {
performOCR(file, objectUrl, imageHash)
} else {
setOcrCache(objectUrl, existingOcr)
setOcrCache(file.name, existingOcr)
}
return objectUrl
}
onMounted(async () => {
document.addEventListener('click', (e) => {
if (!e.target.closest('.export-btn-wrapper')) {
showExportDropdown.value = false
}
})
if (!root.value) throw new Error('root.value is null')
crepe = new Crepe({
root: root.value,
defaultValue: transformSpecialDocBlocksToLegacy(initialMarkdown.value || ''),
features: {
[Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Table]: true,
[Crepe.Feature.ListCheck]: true,
},
featureConfigs: {
[Crepe.Feature.Latex]: {
katexOptions: {},
inlineEditConfirm: 'Escape'
},
[Crepe.Feature.ImageBlock]: {
onUpload: async (file) => {
const objectUrl = await prepareImageFile(file)
if (!objectUrl) return null
clearCurrentGhost()
return objectUrl
}
}
},
config: {
showLineNumber: false,
}
})
const settings = useSettingsStore()
crepe.editor.config((ctx) => {
ctx.set(copilotConfigCtx.key, {
fetchSuggestion,
debounceMs: settings.debounceMs
})
})
crepe.editor.config((ctx) => {
ctx.update(codeBlockConfig.key, (prev) => ({
...prev,
renderPreview: mermaidRenderPreview,
}))
})
watch(() => settings.debounceMs, (newVal) => {
if (!crepe) return
crepe.editor.action((ctx) => {
const config = ctx.get(copilotConfigCtx.key)
ctx.set(copilotConfigCtx.key, {
...config,
debounceMs: newVal
})
})
})
crepe.editor.use(copilotConfigCtx)
crepe.editor.use(copilotGhostMark)
crepe.editor.use(copilotPlugin)
crepe.editor.use(docBlockRemark)
crepe.editor.use(docBlockNode)
crepe.editor.use(docBlockView)
await crepe.create()
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { doc } = view.state
const endPos = doc.content.size
const tr = view.state.tr.setSelection(Selection.near(doc.resolve(endPos), 1))
view.dispatch(tr)
view.focus()
})
crepe.on((listener) => {
listener.updated((ctx, doc) => {
const view = ctx.get(editorViewCtx)
syncObjectUrls(doc)
refreshSizeAndLimit(ctx)
updateHistoryState(view)
refreshDocUploadState(view)
scheduleMarkdownSync()
})
})
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, aiEnabled.value)
refreshSizeAndLimit(ctx)
updateHistoryState(view)
refreshDocUploadState(view)
const editorDom = view.dom
editorCopyHandler = (event) => {
const state = view.state
if (!selectionIncludesDocBlock(state)) return
const { from, to } = state.selection
const rawMarkdown = serializeSelectionToMarkdown(view, from, to)
const clipboardMarkdown = transformDocBlockMarkdownForClipboard(rawMarkdown || '')
if (!clipboardMarkdown) return
event.preventDefault()
event.clipboardData?.setData('text/plain', clipboardMarkdown)
}
editorDom.addEventListener('copy', editorCopyHandler)
ttsMouseUpHandler = (event) => {
setTimeout(() => {
showTTSMenu(event)
}, 10)
}
editorDom.addEventListener('mouseup', ttsMouseUpHandler)
ttsClickOutsideHandler = (event) => {
if (!event.target.closest('.tts-menu') && !event.target.closest('.tts-player')) {
ttsMenuVisible.value = false
}
}
document.addEventListener('mousedown', ttsClickOutsideHandler)
})
scheduleMarkdownSync()
})
const exportMarkdown = async () => {
try {
const markdown = await getExportMarkdown()
const exportName = createExportName()
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
downloadBlob(blob, `${exportName}.md`)
} catch (error) {
console.error('Markdown export failed:', error)
alert(`Markdown 导出失败: ${error.message}`)
}
}
const exportDocx = async () => {
try {
console.log('Exporting DOCX...')
const markdown = await getExportMarkdown()
const blob = await buildDocxBlob(markdown)
const exportName = createExportName()
downloadBlob(blob, `${exportName}.docx`)
console.log('DOCX export completed')
} catch (error) {
console.error('DOCX export failed:', error)
alert(`DOCX导出失败: ${error.message}`)
}
}
const exportPdf = async () => {
try {
console.log('Exporting PDF via DOCX...')
const markdown = await getExportMarkdown()
const docxBlob = await buildDocxBlob(markdown)
const exportName = createExportName()
const formData = new FormData()
formData.append('file', docxBlob, `${exportName}.docx`)
const res = await fetch(EXPORT_PDF_URL, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
},
body: formData,
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
const pdfBlob = await res.blob()
downloadBlob(pdfBlob, `${exportName}.pdf`)
console.log('PDF export completed successfully')
alert('PDF导出成功')
} catch (error) {
console.error('PDF export failed:', error)
alert(`PDF导出失败: ${error.message}`)
}
}
const triggerUpload = () => {
if (isDocUploadDisabled.value) return
uploadInputRef.value?.click()
}
const handleUpload = async (event) => {
const input = event.target
const files = Array.from(input.files || [])
if (files.length === 0) return
const BATCH_LIMIT = 10
const MAX_FILE_SIZE = 50 * 1024 * 1024
if (files.length > BATCH_LIMIT) {
alert(t('uploadBatchLimit') || `一次最多上传${BATCH_LIMIT}个文件`)
input.value = ''
return
}
for (const file of files) {
if (file.size > MAX_FILE_SIZE) {
alert(t('uploadSizeLimit') || `${file.name} 超过${MAX_FILE_SIZE / 1024 / 1024}MB限制`)
input.value = ''
return
}
}
const imageFiles = files.filter(isImageFile)
const docFiles = files.filter(f => !isImageFile(f))
if (docFiles.length > 0 && isDocUploadDisabled.value) {
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
input.value = ''
return
}
for (const file of files) {
const isSupported = isImageFile(file) || isSupportedDocFile(file)
if (!isSupported) {
alert(t('uploadFileTypeWarning') || '不支持的文件类型')
input.value = ''
return
}
}
clearCurrentGhost()
for (const file of imageFiles) {
const objectUrl = await prepareImageFile(file)
if (objectUrl) {
insertImageAtCursor(objectUrl)
}
}
if (docFiles.length > 0) {
uploadProgress.value = { current: 0, total: docFiles.length, filename: '' }
const results = []
const errors = []
const parsePromises = docFiles.map(async (file, index) => {
const docType = getDocTypeFromFilename(file.name)
let content = ''
if (isTextFile(file)) {
content = await file.text()
} else if (isConvertibleFile(file)) {
content = await convertFileToMarkdown(file)
} else {
throw new Error('不支持的文件类型')
}
if (!content) {
throw new Error('文档解析结果为空')
}
return { docType, docName: file.name || `document.${docType}`, content, index }
})
const settled = await Promise.allSettled(parsePromises)
settled.forEach((result, idx) => {
uploadProgress.value = { current: idx + 1, total: docFiles.length, filename: docFiles[idx].name }
if (result.status === 'fulfilled') {
results.push(result.value)
} else {
const message = result.reason instanceof Error ? result.reason.message : String(result.reason)
errors.push({ filename: docFiles[idx].name, message })
}
})
uploadProgress.value = null
results.sort((a, b) => a.index - b.index)
const blocksToInsert = results.map(({ docType, docName, content }) => ({
docType,
docName,
content,
uploadTime: new Date().toISOString(),
collapsed: false,
}))
insertMultipleDocBlocks(blocksToInsert)
if (errors.length > 0) {
const failCount = errors.length
const errorMsgs = errors.map(e => `${e.filename}: ${e.message}`).join('\n')
alert(`上传失败 ${failCount} 个文件:\n\n${errorMsgs}`)
}
}
input.value = ''
}
const triggerImportMd = () => {
mdInputRef.value?.click()
}
const handleImportMd = async (event) => {
const input = event.target
const file = input.files?.[0]
if (!file) return
if (!isMarkdownFile(file)) {
alert(t('uploadMdTypeWarning') || '仅支持 Markdown.md文件')
input.value = ''
return
}
try {
const text = await file.text()
if (crepe && crepe.editor) {
crepe.editor.action(replaceAll(transformSpecialDocBlocksToLegacy(text)))
}
} catch {
// ignore
}
input.value = ''
}
const toggleAI = async () => {
if (isOverLimit.value || !crepe) return
aiEnabled.value = !aiEnabled.value
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, aiEnabled.value)
if (!aiEnabled.value) {
clearCurrentSuggestion(view)
}
})
}
const toggleExportDropdown = () => {
showExportDropdown.value = !showExportDropdown.value
}
const insertImageAtCursor = (src) => {
if (!crepe || !src) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { state } = view
const { schema } = state
const { from, to } = state.selection
const imageType = schema.nodes.image
if (!imageType) return
const imageNode = imageType.create({ src })
const tr = state.tr.replaceRangeWith(from, to, imageNode)
const cursorPos = Math.min(from + imageNode.nodeSize, tr.doc.content.size)
tr.setSelection(Selection.near(tr.doc.resolve(cursorPos), 1))
view.dispatch(tr.scrollIntoView())
})
}
const insertMarkdownAtCursor = (markdown) => {
if (!crepe || !markdown) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { from, to } = view.state.selection
replaceRange(markdown, { from, to })(ctx)
view.focus()
})
}
const insertDocBlockAtCursor = (attrs) => {
if (!crepe) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { state } = view
const { from, to } = state.selection
const docBlockType = state.schema.nodes[DOC_BLOCK_NODE_TYPE]
if (!docBlockType) return
const blockNode = docBlockType.create({
docType: attrs.docType,
docName: attrs.docName,
uploadTime: attrs.uploadTime,
content: attrs.content,
collapsed: Boolean(attrs.collapsed),
})
const tr = state.tr.replaceRangeWith(from, to, blockNode)
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
view.dispatch(tr.scrollIntoView())
view.focus()
})
}
const insertEmptyParagraph = () => {
if (!crepe) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { state } = view
const { from, to } = state.selection
const tr = state.tr.insertText('\n\n', from, to)
const nextPos = from + 2
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
view.dispatch(tr)
})
}
const insertMultipleDocBlocks = (blocks) => {
if (!crepe || blocks.length === 0) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
let tr = view.state.tr
const docBlockType = view.state.schema.nodes[DOC_BLOCK_NODE_TYPE]
if (!docBlockType) return
let currentPos = tr.selection.from
blocks.forEach((block, index) => {
const maxPos = tr.doc.content.size
const insertPos = Math.min(currentPos, maxPos)
const blockNode = docBlockType.create({
docType: block.docType,
docName: block.docName,
uploadTime: block.uploadTime,
content: block.content,
collapsed: Boolean(block.collapsed),
})
tr = tr.replaceRangeWith(insertPos, insertPos, blockNode)
currentPos = insertPos + blockNode.nodeSize
})
const finalPos = Math.min(currentPos, tr.doc.content.size)
if (finalPos >= 0 && finalPos <= tr.doc.content.size) {
tr.setSelection(Selection.near(tr.doc.resolve(finalPos), 1))
}
view.dispatch(tr.scrollIntoView())
view.focus()
})
}
onUnmounted(() => {
if (markdownSyncTimer) {
clearTimeout(markdownSyncTimer)
markdownSyncTimer = null
}
for (const url of Array.from(objectUrls)) {
revokeObjectUrl(url)
}
clearAllOcrCache()
if (crepe) {
if (editorCopyHandler) {
crepe.editor.action((ctx) => {
ctx.get(editorViewCtx).dom.removeEventListener('copy', editorCopyHandler)
})
editorCopyHandler = null
}
if (ttsMouseUpHandler) {
crepe.editor.action((ctx) => {
ctx.get(editorViewCtx).dom.removeEventListener('mouseup', ttsMouseUpHandler)
})
ttsMouseUpHandler = null
}
crepe.destroy()
crepe = null
}
if (ttsClickOutsideHandler) {
document.removeEventListener('mousedown', ttsClickOutsideHandler)
ttsClickOutsideHandler = null
}
})
</script>
<style scoped>
.editor-container {
position: relative;
width: 100vw;
height: 100vh;
}
.history-buttons {
position: fixed;
bottom: 20px;
left: 80px;
display: flex;
gap: 6px;
z-index: 9000;
}
.history-btn {
width: 34px;
height: 34px;
padding: 8px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--btn-bg);
color: var(--btn-fg);
box-shadow: var(--panel-shadow);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.72;
}
.history-btn:hover:not(:disabled) {
background-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
opacity: 1;
}
.history-btn:disabled {
background-color: var(--btn-disabled-bg);
color: var(--btn-disabled-fg);
border-color: var(--btn-disabled-bg);
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
}
.action-buttons {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 99999;
transform: translateZ(0);
}
.action-btn {
width: 44px;
height: 44px;
padding: 10px;
background-color: var(--btn-bg);
color: var(--btn-fg);
border: 1px solid var(--panel-border);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--panel-shadow);
opacity: 0.5;
}
.action-btn:hover {
background-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
opacity: 1;
}
.action-btn.ai-disabled {
background-color: var(--crepe-color-surface-low);
color: var(--crepe-color-on-background);
border-color: var(--panel-border);
}
.action-btn.ai-disabled:hover {
background-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
}
.action-btn.force-disabled {
background-color: var(--btn-disabled-bg);
color: var(--btn-disabled-fg);
border-color: var(--btn-disabled-bg);
cursor: not-allowed;
opacity: 0.6;
}
.action-btn.force-disabled:hover {
background-color: var(--btn-disabled-bg);
color: var(--btn-disabled-fg);
border-color: var(--btn-disabled-bg);
opacity: 0.6;
}
.size-indicator {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 10px;
font-size: 10px;
color: var(--muted-text);
border-radius: 12px;
transition: all 0.3s ease;
cursor: default;
}
.size-indicator.over-limit {
color: var(--danger-text);
background: rgba(220, 38, 38, 0.08);
animation: pulse-warning 2s ease-in-out infinite;
}
.warning-icon {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.warning-icon--visible {
opacity: 1;
}
@keyframes pulse-warning {
0%, 100% {
opacity: 1;
background: rgba(220, 38, 38, 0.08);
}
50% {
opacity: 0.75;
background: rgba(220, 38, 38, 0.12);
}
}
.size-tooltip {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
padding: 8px 12px;
background: var(--tooltip-bg);
color: var(--tooltip-fg);
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
box-shadow: var(--panel-shadow);
display: flex;
flex-direction: column;
gap: 2px;
}
.size-tooltip strong {
font-weight: 600;
}
.size-tooltip span {
opacity: 0.85;
font-size: 11px;
}
.tooltip-fade-enter-active,
.tooltip-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.tooltip-fade-enter-from,
.tooltip-fade-leave-to {
opacity: 0;
transform: translateY(4px);
}
.action-btn {
position: relative;
}
.btn-tooltip {
position: absolute;
top: 50%;
right: 100%;
transform: translateY(-50%);
margin-right: 8px;
background: var(--tooltip-bg);
color: var(--tooltip-fg);
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.action-btn:hover .btn-tooltip {
opacity: 1;
}
.action-btn:focus-visible .btn-tooltip {
opacity: 1;
}
.image-btn-wrapper {
position: relative;
}
.image-dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
box-shadow: var(--panel-shadow);
overflow: hidden;
z-index: 10000;
min-width: 160px;
}
.image-dropdown button {
display: block;
width: 100%;
padding: 10px 16px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 14px;
color: var(--app-text);
}
.image-dropdown button:hover {
background: var(--crepe-color-hover);
}
.export-btn-wrapper {
position: relative;
}
.export-dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
box-shadow: var(--panel-shadow);
overflow: hidden;
z-index: 10000;
min-width: 160px;
}
.export-dropdown button {
display: block;
width: 100%;
padding: 10px 16px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 14px;
color: var(--app-text);
}
.export-dropdown button:hover {
background: var(--crepe-color-hover);
}
.url-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
}
.url-dialog {
background: var(--panel-bg);
padding: 20px;
border-radius: 8px;
border: 1px solid var(--panel-border);
box-shadow: var(--panel-shadow);
min-width: 320px;
}
.url-dialog h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: var(--app-text);
}
.url-dialog input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid var(--panel-border);
border-radius: 4px;
font-size: 14px;
margin-bottom: 16px;
color: var(--app-text);
background: var(--crepe-color-background);
}
.url-dialog input:focus {
outline: none;
border-color: var(--focus-ring);
}
.url-dialog-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.dialog-btn {
padding: 8px 16px;
border: 1px solid var(--panel-border);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
background: var(--btn-bg);
color: var(--btn-fg);
}
.dialog-btn:hover {
background: var(--crepe-color-hover);
}
.dialog-btn.primary {
background: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
}
.dialog-btn.primary:hover {
filter: brightness(0.92);
}
.milkdown-editor {
width: 100%;
height: 100%;
background-color: transparent !important;
overflow-y: auto;
}
.milkdown-editor :deep(.milkdown) {
max-width: none;
margin: 0 !important;
padding: 0 40px !important;
min-height: 100%;
}
.milkdown-editor :deep(.milkdown__main) {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
.milkdown-editor :deep(.milkdown__editor) {
margin: 0 !important;
padding: 0 !important;
}
.milkdown-editor :deep(.milkdown > *:first-child) {
margin-top: 0 !important;
padding-top: 0 !important;
}
.milkdown-editor :deep(.ProseMirror) {
margin: 0 !important;
padding: 10px 0 24px 0 !important;
}
.milkdown-editor :deep(.ProseMirror img) {
max-width: 60%;
height: auto;
}
.milkdown-editor :deep(.ProseMirror > *:first-child) {
margin-top: 0 !important;
}
.milkdown-editor :deep(.milkdown__aside),
.milkdown-editor :deep(.milkdown__aside-wrapper),
.milkdown-editor :deep([class*="aside"]),
.milkdown-editor :deep([class*="line-number"]),
.milkdown-editor :deep([class*="gutter"]),
.milkdown-editor :deep([class*="sidebar"]) {
display: none !important;
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
}
.milkdown-editor::-webkit-scrollbar {
width: 8px;
}
.milkdown-editor::-webkit-scrollbar-track {
background: transparent;
}
.milkdown-editor::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 4px;
}
.milkdown-editor::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
.milkdown-editor :deep(.milkdown__toolbar),
.milkdown-editor :deep(.milkdown__menu),
.milkdown-editor :deep(.milkdown__statusbar),
.milkdown-editor :deep(.milkdown-slate-toolbar),
.milkdown-editor :deep(.milkdown-bubble-menu),
.milkdown-editor :deep([class*="toolbar"]),
.milkdown-editor :deep([class*="menu"]) {
display: none !important;
visibility: hidden !important;
height: 0 !important;
width: 0 !important;
}
.milkdown-editor :deep(.milkdown__block-handle),
.milkdown-editor :deep([class*="block-handle"]),
.milkdown-editor :deep([class*="blockHandle"]) {
display: none !important;
visibility: hidden !important;
width: 0 !important;
min-width: 0 !important;
}
</style>
<style>
.copilot-ghost-text {
color: var(--ghost-text);
opacity: 0.72;
pointer-events: auto;
transition: color 0.12s ease, opacity 0.12s ease, background-color 0.12s ease;
}
.copilot-ghost-text.copilot-loading {
opacity: 0.4;
}
.copilot-ghost-text strong,
.copilot-ghost-text em,
.copilot-ghost-text code,
.copilot-ghost-text a {
color: inherit;
opacity: inherit;
}
.copilot-ghost-text code {
background-color: var(--ghost-code-bg);
padding: 0.2em 0.4em;
border-radius: 3px;
}
.copilot-ghost-text a {
text-decoration: underline;
}
.copilot-ghost-block {
color: var(--ghost-text);
opacity: 0.72;
transition: color 0.12s ease, opacity 0.12s ease, background-color 0.12s ease;
}
.copilot-ghost-block code,
.copilot-ghost-block pre,
.copilot-ghost-block a {
color: inherit;
opacity: inherit;
}
.copilot-ghost-block pre,
.copilot-ghost-block code {
background-color: var(--ghost-code-bg);
}
.upload-progress-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.upload-progress-dialog {
background: var(--editor-bg, white);
padding: 24px 32px;
border-radius: 8px;
text-align: center;
max-width: 400px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.spinner {
width: 40px;
height: 40px;
margin: 0 auto 16px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.progress-text {
font-size: 18px;
font-weight: 600;
margin: 8px 0;
}
.filename {
font-size: 14px;
color: #666;
word-break: break-all;
}
</style>