- 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.
1817 lines
49 KiB
Vue
1817 lines
49 KiB
Vue
<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>
|
||
|
||
|