refactor: remove FFmpeg dependencies and related video processing logic

- Deleted FFmpeg related packages from package.json and package-lock.json.
- Removed video transcoding and editing functionalities from FileContent.vue.
- Simplified video handling logic and error management in FileContent.vue.
- Added a clear button in MilkdownEditor.vue for clearing the editor content.
- Enhanced UniverPreview.vue to clear detached popups and mount nodes on destroy.
- Updated docBlockPlugin.ts to improve context handling for document blocks.
- Cleaned up vite.config.js by removing cross-origin isolation headers.
This commit is contained in:
2026-05-03 19:14:55 +08:00
parent 477f090dfa
commit 6dc9933853
8 changed files with 181 additions and 749 deletions
+1 -52
View File
@@ -9,10 +9,6 @@
"version": "0.0.0",
"dependencies": {
"@blocknote/xl-docx-exporter": "^0.47.3",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/core-mt": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@milkdown/core": "^7.18.0",
"@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0",
@@ -34,6 +30,7 @@
"mermaid": "^11.12.3",
"pinia": "^2.3.1",
"prismjs": "^1.29.0",
"tui-color-picker": "^2.2.8",
"tui-image-editor": "^3.15.3",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
@@ -1212,54 +1209,6 @@
"node": ">=18"
}
},
"node_modules/@ffmpeg/core": {
"version": "0.12.10",
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
"license": "GPL-2.0-or-later",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/core-mt": {
"version": "0.12.10",
"resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz",
"integrity": "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==",
"license": "GPL-2.0-or-later",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/ffmpeg": {
"version": "0.12.15",
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
"integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==",
"license": "MIT",
"dependencies": {
"@ffmpeg/types": "^0.12.4"
},
"engines": {
"node": ">=18.x"
}
},
"node_modules/@ffmpeg/types": {
"version": "0.12.4",
"resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz",
"integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==",
"license": "MIT",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/util": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz",
"integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==",
"license": "MIT",
"engines": {
"node": ">=18.x"
}
},
"node_modules/@flatten-js/interval-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.3.tgz",
+2 -4
View File
@@ -12,10 +12,6 @@
},
"dependencies": {
"@blocknote/xl-docx-exporter": "^0.47.3",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/core-mt": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@milkdown/core": "^7.18.0",
"@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0",
@@ -37,6 +33,8 @@
"mermaid": "^11.12.3",
"pinia": "^2.3.1",
"prismjs": "^1.29.0",
"tui-color-picker": "^2.2.8",
"tui-image-editor": "^3.15.3",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
+16 -444
View File
@@ -2,10 +2,11 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
import { canPlayVideoNatively, editVideoBlob, isVideoFile, transcodeVideoToMp4 } from '../utils/ffmpeg'
import ImageEditorComponent from './ImageEditorComponent.vue'
import UniverPreview from './UniverPreview.vue'
const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogv', 'ogg', 'mov', 'm4v'])
const props = defineProps({
node: { type: Object, default: null },
breadcrumb: { type: Array, default: () => [] },
@@ -25,26 +26,11 @@ const md = new MarkdownIt({
})
const basePreviewUrl = ref('')
const editedVideoUrl = ref('')
const editedVideoBlob = ref(null)
const imageEditorRef = ref(null)
const imageEditorError = ref('')
const isEditingImage = ref(false)
const isSavingImage = ref(false)
const isTranscodingVideo = ref(false)
const transcodeProgress = ref(0)
const videoPreviewError = ref('')
const isUsingTranscodedVideo = ref(false)
const isApplyingVideoEdit = ref(false)
const videoEditProgress = ref(0)
const videoEditError = ref('')
const videoElement = ref(null)
const videoDurationSeconds = ref(0)
const videoCurrentTime = ref(0)
const clipStartSeconds = ref(0)
const clipEndSeconds = ref(0)
const muteVideoAudio = ref(false)
let previewTaskId = 0
const isRoot = computed(() => !props.node)
const isFolder = computed(() => props.node?.type === 'folder')
@@ -62,7 +48,10 @@ const isPdf = computed(() => {
const mime = String(props.node?.mimeType || '')
return mime === 'application/pdf' || fileExt.value === 'pdf'
})
const isVideo = computed(() => isVideoFile(props.node))
const isVideo = computed(() => {
const mime = String(props.node?.mimeType || '').toLowerCase()
return mime.startsWith('video/') || VIDEO_EXTENSIONS.has(fileExt.value)
})
const isText = computed(() => {
const mime = String(props.node?.mimeType || '')
return Boolean(props.node?.content || props.node?.previewText) || mime.startsWith('text/') || mime.includes('json') || ['txt', 'json', 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'htm', 'py', 'vue', 'xml', 'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg', 'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp', 'go', 'rs'].includes(fileExt.value)
@@ -93,144 +82,33 @@ const locLabel = computed(() => {
const count = lineCount.value
return count ? `${count}` : '二进制文件'
})
const objectUrl = computed(() => editedVideoUrl.value || basePreviewUrl.value)
const objectUrl = computed(() => basePreviewUrl.value)
const canEditImage = computed(() => isImage.value && typeof props.updateFile === 'function')
const hasEditedVideo = computed(() => editedVideoBlob.value instanceof Blob)
const normalizedClipStart = computed(() => clampNumber(clipStartSeconds.value, 0, videoDurationSeconds.value || 0))
const normalizedClipEnd = computed(() => resolveClipEnd(clipEndSeconds.value, normalizedClipStart.value, videoDurationSeconds.value || 0))
const selectedClipDuration = computed(() => Math.max(0, normalizedClipEnd.value - normalizedClipStart.value))
const hasPendingVideoEdit = computed(() => {
if (!isVideo.value || !videoDurationSeconds.value) return false
const duration = videoDurationSeconds.value
return normalizedClipStart.value > 0.05 || Math.abs(normalizedClipEnd.value - duration) > 0.05 || muteVideoAudio.value
})
function clampNumber(value, min, max) {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return min
return Math.min(Math.max(numeric, min), max)
}
function roundSeconds(value = 0) {
return Math.round((Number(value) || 0) * 10) / 10
}
function resolveClipEnd(value, start, duration) {
if (!duration) return 0
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) return duration
return clampNumber(numeric, start, duration)
}
function stripExtension(name = '') {
return String(name).replace(/\.[^.]+$/, '')
}
function buildEditedVideoName(name = 'video.mp4') {
return `${stripExtension(name)}-edited.mp4`
}
function revokeUrl(url = '') {
if (!url) return
URL.revokeObjectURL(url)
}
function clearEditedVideoResult() {
revokeUrl(editedVideoUrl.value)
editedVideoUrl.value = ''
editedVideoBlob.value = null
}
function clearBasePreview() {
revokeUrl(basePreviewUrl.value)
basePreviewUrl.value = ''
}
function resetVideoPreviewState() {
isTranscodingVideo.value = false
transcodeProgress.value = 0
videoPreviewError.value = ''
isUsingTranscodedVideo.value = false
}
function resetVideoEditState() {
isApplyingVideoEdit.value = false
videoEditProgress.value = 0
videoEditError.value = ''
videoDurationSeconds.value = 0
videoCurrentTime.value = 0
clipStartSeconds.value = 0
clipEndSeconds.value = 0
muteVideoAudio.value = false
clearEditedVideoResult()
}
function normalizeVideoEditRange() {
if (!videoDurationSeconds.value) {
clipStartSeconds.value = 0
clipEndSeconds.value = 0
return
}
clipStartSeconds.value = roundSeconds(normalizedClipStart.value)
clipEndSeconds.value = roundSeconds(normalizedClipEnd.value)
}
function assignBasePreviewUrl(url = '') {
clearBasePreview()
basePreviewUrl.value = url
}
function setVideoRangeFromDuration(duration = 0) {
videoDurationSeconds.value = duration
videoCurrentTime.value = 0
clipStartSeconds.value = 0
clipEndSeconds.value = roundSeconds(duration)
}
watch(
[fileBlob, () => props.node?.id],
async ([blob]) => {
const currentTaskId = ++previewTaskId
([blob]) => {
clearBasePreview()
resetVideoPreviewState()
resetVideoEditState()
videoPreviewError.value = ''
if (!(blob instanceof Blob)) return
if (!isVideo.value) {
assignBasePreviewUrl(URL.createObjectURL(blob))
return
}
if (canPlayVideoNatively(props.node)) {
assignBasePreviewUrl(URL.createObjectURL(blob))
return
}
isTranscodingVideo.value = true
try {
const transcodedBlob = await transcodeVideoToMp4(blob, props.node?.name || 'video', {
onProgress(progress) {
if (currentTaskId !== previewTaskId) return
transcodeProgress.value = Math.max(transcodeProgress.value, Math.round(progress * 100))
}
})
if (currentTaskId !== previewTaskId) return
assignBasePreviewUrl(URL.createObjectURL(transcodedBlob))
isUsingTranscodedVideo.value = true
} catch (error) {
if (currentTaskId !== previewTaskId) return
videoPreviewError.value = error instanceof Error && error.message
? error.message
: '当前视频暂时无法在浏览器内转换预览'
} finally {
if (currentTaskId === previewTaskId) {
isTranscodingVideo.value = false
}
}
assignBasePreviewUrl(URL.createObjectURL(blob))
},
{ immediate: true }
)
@@ -245,9 +123,7 @@ watch(
)
onBeforeUnmount(() => {
previewTaskId += 1
clearBasePreview()
clearEditedVideoResult()
})
function formatBytes(bytes = 0) {
@@ -330,20 +206,6 @@ async function saveImageEdits() {
}
}
function formatDuration(seconds = 0) {
const total = Math.max(0, Number(seconds) || 0)
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60
const paddedSeconds = secs >= 10 ? secs.toFixed(1).padStart(4, '0') : secs.toFixed(1).padStart(3, '0')
if (hours > 0) {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
}
return `${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
}
function downloadBlob(blob, fileName) {
if (!(blob instanceof Blob)) return
const url = URL.createObjectURL(blob)
@@ -356,79 +218,8 @@ function downloadBlob(blob, fileName) {
setTimeout(() => URL.revokeObjectURL(url), 0)
}
function handleVideoLoadedMetadata(event) {
const duration = Number(event.target?.duration || 0)
if (!Number.isFinite(duration)) return
setVideoRangeFromDuration(duration)
}
function handleVideoTimeUpdate(event) {
const currentTime = Number(event.target?.currentTime || 0)
if (!Number.isFinite(currentTime)) return
videoCurrentTime.value = currentTime
}
function setClipStartFromCurrentTime() {
if (!videoElement.value) return
clipStartSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
if (clipEndSeconds.value < clipStartSeconds.value) {
clipEndSeconds.value = roundSeconds(videoDurationSeconds.value || clipStartSeconds.value)
}
normalizeVideoEditRange()
}
function setClipEndFromCurrentTime() {
if (!videoElement.value) return
clipEndSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
if (clipEndSeconds.value < clipStartSeconds.value) {
clipStartSeconds.value = roundSeconds(Math.max(0, clipEndSeconds.value - 0.1))
}
normalizeVideoEditRange()
}
async function applyVideoEdits() {
if (!isVideo.value) return
normalizeVideoEditRange()
const sourceBlob = hasEditedVideo.value ? editedVideoBlob.value : fileBlob.value
if (!(sourceBlob instanceof Blob) || !videoDurationSeconds.value || !hasPendingVideoEdit.value) return
isApplyingVideoEdit.value = true
videoEditProgress.value = 0
videoEditError.value = ''
try {
const editedBlob = await editVideoBlob(sourceBlob, hasEditedVideo.value ? buildEditedVideoName(props.node?.name || 'video.mp4') : props.node?.name || 'video.mp4', {
startTime: normalizedClipStart.value,
endTime: normalizedClipEnd.value,
muteAudio: muteVideoAudio.value,
onProgress(progress) {
videoEditProgress.value = Math.max(videoEditProgress.value, Math.round(progress * 100))
}
})
clearEditedVideoResult()
editedVideoBlob.value = editedBlob
editedVideoUrl.value = URL.createObjectURL(editedBlob)
} catch (error) {
videoEditError.value = error instanceof Error && error.message
? error.message
: '视频编辑失败'
} finally {
isApplyingVideoEdit.value = false
}
}
function restoreOriginalVideo() {
clearEditedVideoResult()
videoEditError.value = ''
videoEditProgress.value = 0
muteVideoAudio.value = false
}
function downloadEditedVideo() {
if (!(editedVideoBlob.value instanceof Blob)) return
downloadBlob(editedVideoBlob.value, buildEditedVideoName(props.node?.name || 'video.mp4'))
function handleVideoError() {
videoPreviewError.value = '当前浏览器无法直接播放这个视频格式,请下载后使用本地播放器打开。'
}
async function copyText() {
@@ -536,9 +327,7 @@ function downloadFile() {
<span class="file-meta">{{ fileSizeLabel }}</span>
<span class="file-meta">{{ locLabel }}</span>
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</span>
<span v-else-if="isTranscodingVideo" class="truncated-pill">视频转换中 {{ transcodeProgress }}%</span>
<span v-else-if="isApplyingVideoEdit" class="truncated-pill">编辑处理中 {{ videoEditProgress }}%</span>
<span v-else-if="isUsingTranscodedVideo" class="truncated-pill">已转为 MP4 预览</span>
<span v-else-if="isVideo && videoPreviewError" class="truncated-pill">视频预览不可用</span>
</div>
</div>
@@ -591,16 +380,7 @@ function downloadFile() {
<div v-else-if="isVideo" class="content-preview">
<div class="preview-surface video-surface">
<div v-if="isTranscodingVideo" class="video-state-card">
<h3>正在准备视频预览</h3>
<p>当前格式需要先转换为浏览器兼容的 MP4转换完成后会自动开始播放</p>
<div class="video-progress-track" role="progressbar" :aria-valuenow="transcodeProgress" aria-valuemin="0" aria-valuemax="100">
<span class="video-progress-bar" :style="{ width: `${transcodeProgress}%` }"></span>
</div>
<strong>{{ transcodeProgress }}%</strong>
</div>
<div v-else-if="videoPreviewError" class="video-state-card video-state-error">
<div v-if="videoPreviewError" class="video-state-card video-state-error">
<h3>视频预览暂时不可用</h3>
<p>{{ videoPreviewError }}</p>
<p>原始文件仍保存在浏览器本地你可以继续下载后使用本地播放器打开</p>
@@ -608,66 +388,12 @@ function downloadFile() {
<video
v-else-if="objectUrl"
ref="videoElement"
class="video-player"
controls
playsinline
:src="objectUrl"
@loadedmetadata="handleVideoLoadedMetadata"
@timeupdate="handleVideoTimeUpdate"
@error="handleVideoError"
></video>
<section v-if="objectUrl" class="video-edit-panel">
<div class="video-edit-header">
<div>
<h3>基础编辑</h3>
<p>裁剪片段静音导出并把结果另存为新的 MP4 文件</p>
</div>
<div class="video-edit-meta">
<span>总时长 {{ formatDuration(videoDurationSeconds) }}</span>
<span>当前播放 {{ formatDuration(videoCurrentTime) }}</span>
<span>片段时长 {{ formatDuration(selectedClipDuration) }}</span>
</div>
</div>
<div class="video-edit-grid">
<label class="video-field">
<span>开始时间</span>
<input v-model.number="clipStartSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
</label>
<label class="video-field">
<span>结束时间</span>
<input v-model.number="clipEndSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
</label>
</div>
<div class="video-quick-actions">
<button class="ghost-btn" type="button" @click="setClipStartFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为开始</button>
<button class="ghost-btn" type="button" @click="setClipEndFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为结束</button>
</div>
<label class="video-checkbox">
<input v-model="muteVideoAudio" type="checkbox" />
<span>静音导出移除音轨</span>
</label>
<div v-if="videoEditError" class="video-inline-error">{{ videoEditError }}</div>
<div v-if="isApplyingVideoEdit" class="video-edit-progress-shell">
<div class="video-progress-track" role="progressbar" :aria-valuenow="videoEditProgress" aria-valuemin="0" aria-valuemax="100">
<span class="video-progress-bar" :style="{ width: `${videoEditProgress}%` }"></span>
</div>
<strong>{{ videoEditProgress }}%</strong>
</div>
<div class="video-edit-actions">
<button class="action-btn primary-btn" type="button" @click="applyVideoEdits" :disabled="isApplyingVideoEdit || !hasPendingVideoEdit">生成编辑预览</button>
<button class="action-btn" type="button" @click="downloadEditedVideo" :disabled="!hasEditedVideo">下载编辑结果</button>
<button class="action-btn" type="button" @click="restoreOriginalVideo" :disabled="!hasEditedVideo">恢复原片</button>
</div>
</section>
</div>
</div>
@@ -677,6 +403,7 @@ function downloadFile() {
<div v-else-if="isOffice && fileBlob && officeFormat" class="content-preview">
<UniverPreview
:key="`${node?.id || node?.name || 'office'}:${officeFormat}:${fileBlob?.size || 0}`"
:fileBlob="fileBlob"
:fileName="node.name"
:format="officeFormat"
@@ -1081,144 +808,6 @@ function downloadFile() {
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
}
.video-edit-panel {
width: min(1100px, 100%);
margin: 0 auto;
padding: 20px;
border: 1px solid var(--github-border);
border-radius: 16px;
background: color-mix(in srgb, var(--github-bg) 94%, white 6%);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}
.video-edit-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.video-edit-header h3 {
margin: 0;
}
.video-edit-header p {
margin: 6px 0 0;
color: var(--github-text-secondary);
line-height: 1.7;
}
.video-edit-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.video-edit-meta span {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--github-border);
border-radius: 999px;
background: var(--github-hover);
color: var(--github-text-secondary);
font-size: 12px;
font-weight: 600;
}
.video-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.video-field {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--github-text-secondary);
font-size: 13px;
font-weight: 600;
}
.video-field input {
height: 40px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 10px;
background: var(--github-bg);
color: var(--github-text);
}
.video-quick-actions,
.video-edit-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.video-quick-actions {
margin-top: 14px;
}
.ghost-btn {
height: 34px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 999px;
background: transparent;
color: var(--github-text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.ghost-btn:hover:enabled {
background: var(--github-hover);
color: var(--github-text);
}
.ghost-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.video-checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 14px;
color: var(--github-text);
font-size: 13px;
font-weight: 600;
}
.video-inline-error {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, #cf222e 35%, var(--github-border) 65%);
border-radius: 10px;
background: color-mix(in srgb, #cf222e 8%, var(--github-bg) 92%);
color: #cf222e;
font-size: 13px;
}
.video-edit-progress-shell {
margin-top: 16px;
}
.video-edit-progress-shell strong {
display: inline-block;
margin-top: 8px;
}
.video-edit-actions {
margin-top: 18px;
}
.primary-btn {
border-color: #0969da;
background: #0969da;
@@ -1259,23 +848,6 @@ function downloadFile() {
border-color: color-mix(in srgb, #cf222e 40%, var(--github-border) 60%);
}
.video-progress-track {
position: relative;
height: 10px;
margin-top: 18px;
overflow: hidden;
border-radius: 999px;
background: var(--github-hover);
}
.video-progress-bar {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #0969da, #2da44e);
transition: width 180ms ease;
}
.pdf-frame {
width: 100%;
min-height: 78vh;
+30
View File
@@ -141,6 +141,21 @@
</Transition>
</div>
</div>
<div class="top-actions-fixed">
<button
type="button"
class="action-btn"
aria-label="清除"
title="清除"
@click="clearEditor"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
<span class="btn-tooltip">清除文档</span>
</button>
</div>
</div>
<div v-if="uploadProgress" class="upload-progress-overlay">
@@ -277,6 +292,12 @@ const createExportName = () => {
return `save${datePart}${timePart}`
}
const clearEditor = () => {
if (crepe && crepe.editor) {
crepe.editor.action(replaceAll(''))
}
}
const buildDocxBlob = async (markdown) => {
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
const children = []
@@ -1319,6 +1340,15 @@ for (const url of Array.from(objectUrls)) {
transform: translateZ(0);
}
.top-actions-fixed {
position: fixed;
top: 20px;
right: 20px;
display: flex;
gap: 8px;
z-index: 99999;
}
.action-btn {
width: 44px;
height: 44px;
+35 -5
View File
@@ -96,6 +96,21 @@ const retry = () => {
initializeUniver();
};
const clearMountNode = () => {
if (!univerContainer.value) return;
univerContainer.value.replaceChildren();
univerContainer.value.removeAttribute('style');
};
const clearDetachedPopups = () => {
Array.from(document.body.children).forEach((child) => {
if (!(child instanceof HTMLElement)) return;
if (child.querySelector('.univer-popup')) {
child.remove();
}
});
};
const computePresetFromFormat = (fmt) => {
const lower = (fmt || '').toLowerCase();
if (lower === 'docx') return OfficePresetType.DOCS;
@@ -105,6 +120,7 @@ const computePresetFromFormat = (fmt) => {
};
const initializeUniver = async () => {
destroyUniver(true);
loading.value = true;
error.value = null;
@@ -162,10 +178,21 @@ const loadDocumentIntoUniver = async (instance, blob, fileName, fmt) => {
}
};
const destroyUniver = () => {
if (univerInstance.value?.univer) {
univerInstance.value.univer.dispose();
univerInstance.value = null;
const destroyUniver = (clearMount = false, skipDispose = false) => {
const currentUniver = univerInstance.value?.univer;
univerInstance.value = null;
if (currentUniver && !skipDispose) {
try {
currentUniver.dispose();
} catch (e) {
console.warn('[Univer] 销毁实例时出现异常,已回退为强制清空挂载节点', e);
}
}
if (clearMount) {
clearMountNode();
clearDetachedPopups();
}
};
@@ -184,7 +211,10 @@ onMounted(async () => {
onBeforeUnmount(() => {
themeObserver?.disconnect();
destroyUniver();
destroyUniver(false, props.format === 'xlsx');
if (props.format === 'xlsx') {
clearDetachedPopups();
}
});
</script>
+97 -5
View File
@@ -18,6 +18,8 @@ import {
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
const FALLBACK_LEAF_TEXT = '\n'
const CONTEXT_SEPARATOR = '\n\n'
const MAX_SUFFIX_RATIO = 0.35
function serializeRangeToMarkdown(
doc: ProseNode,
@@ -33,6 +35,75 @@ function serializeRangeToMarkdown(
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
}
function getJoinedLength(parts: string[]) {
let total = 0
let hasContent = false
for (const part of parts) {
if (!part) continue
if (hasContent) total += CONTEXT_SEPARATOR.length
total += part.length
hasContent = true
}
return total
}
function takeTail(text: string, limit: number) {
if (!text || limit <= 0) return ''
if (text.length <= limit) return text
return text.slice(-limit)
}
function takeHead(text: string, limit: number) {
if (!text || limit <= 0) return ''
if (text.length <= limit) return text
return text.slice(0, limit)
}
function fitPrefixSections(parts: string[], limit: number) {
if (limit <= 0) return ''
const fitted: string[] = []
let remaining = limit
for (let index = parts.length - 1; index >= 0; index -= 1) {
const part = parts[index]
if (!part || remaining <= 0) continue
const separatorCost = fitted.length > 0 ? CONTEXT_SEPARATOR.length : 0
if (remaining <= separatorCost) break
const nextPart = takeTail(part, remaining - separatorCost)
if (!nextPart) continue
fitted.unshift(nextPart)
remaining -= nextPart.length + separatorCost
}
return fitted.join(CONTEXT_SEPARATOR)
}
function fitSuffixSections(parts: string[], limit: number) {
if (limit <= 0) return ''
const fitted: string[] = []
let remaining = limit
for (const part of parts) {
if (!part || remaining <= 0) continue
const separatorCost = fitted.length > 0 ? CONTEXT_SEPARATOR.length : 0
if (remaining <= separatorCost) break
const nextPart = takeHead(part, remaining - separatorCost)
if (!nextPart) continue
fitted.push(nextPart)
remaining -= nextPart.length + separatorCost
}
return fitted.join(CONTEXT_SEPARATOR)
}
function buildDocContext(doc: ProseNode, excludePos?: number) {
const blocks: string[] = []
doc.descendants((node, pos) => {
@@ -112,16 +183,37 @@ class DocBlockNodeView implements NodeView {
const before = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer))
const after = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, pos + this.node.nodeSize, doc.content.size, schema, this.serializer))
const docContext = buildDocContext(doc, pos)
const mergedPrefix = [docContext, before, payload.prefix].filter(Boolean).join('\n\n')
const mergedSuffix = [payload.suffix, after].filter(Boolean).join('\n\n')
const prefixParts = [docContext, before, payload.prefix].filter(Boolean)
const suffixParts = [payload.suffix, after].filter(Boolean)
const mergedPrefix = prefixParts.join(CONTEXT_SEPARATOR)
const mergedSuffix = suffixParts.join(CONTEXT_SEPARATOR)
if (mergedPrefix.length + mergedSuffix.length > DOC_CONTEXT_LIMIT) {
const prefixCapacity = getJoinedLength(prefixParts)
const suffixCapacity = getJoinedLength(suffixParts)
const maxSuffixBudget = Math.min(suffixCapacity, Math.floor(DOC_CONTEXT_LIMIT * MAX_SUFFIX_RATIO))
let suffixBudget = maxSuffixBudget
let prefixBudget = DOC_CONTEXT_LIMIT - suffixBudget
if (prefixCapacity < prefixBudget) {
const transferable = prefixBudget - prefixCapacity
suffixBudget = Math.min(suffixCapacity, suffixBudget + transferable)
prefixBudget = DOC_CONTEXT_LIMIT - suffixBudget
} else if (suffixCapacity < suffixBudget) {
const transferable = suffixBudget - suffixCapacity
prefixBudget = Math.min(prefixCapacity, prefixBudget + transferable)
suffixBudget = DOC_CONTEXT_LIMIT - prefixBudget
}
return {
prefix: mergedPrefix.slice(0, DOC_CONTEXT_LIMIT),
suffix: '',
prefix: fitPrefixSections(prefixParts, prefixBudget),
suffix: fitSuffixSections(suffixParts, suffixBudget),
languageId: payload.languageId,
blocked: true,
blocked: false,
}
}
return {
prefix: mergedPrefix,
suffix: mergedSuffix,
-230
View File
@@ -1,230 +0,0 @@
import coreURL from '@ffmpeg/core?url'
import wasmURL from '@ffmpeg/core/wasm?url'
import classWorkerURL from '@ffmpeg/ffmpeg/worker?url'
const VIDEO_EXTENSIONS = new Set([
'mp4',
'webm',
'ogv',
'ogg',
'mov',
'm4v',
'avi',
'mkv',
'flv',
'wmv',
'3gp',
'm2ts',
'mts',
'ts'
])
const FORCED_TRANSCODE_EXTENSIONS = new Set([
'3gp',
'avi',
'flv',
'm2ts',
'mkv',
'mts',
'ts',
'wmv'
])
const VIDEO_MIME_BY_EXTENSION = {
m4v: 'video/x-m4v',
mov: 'video/quicktime',
mp4: 'video/mp4',
ogg: 'video/ogg',
ogv: 'video/ogg',
webm: 'video/webm'
}
let ffmpegInstancePromise = null
let progressHandler = null
let transcodeQueue = Promise.resolve()
function getFileExtension(name = '') {
const parts = String(name).split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
}
function getNodeMimeType(node) {
return String(node?.mimeType || '').toLowerCase()
}
function formatFfmpegTime(seconds = 0) {
const totalSeconds = Math.max(0, Number(seconds) || 0)
return totalSeconds.toFixed(3)
}
function createVideoProbe() {
if (typeof document === 'undefined') return null
return document.createElement('video')
}
function toAbsoluteURL(url) {
if (!url || typeof location === 'undefined') {
return url
}
return new URL(url, location.href).href
}
function getLoadConfigs() {
return [{
classWorkerURL: toAbsoluteURL(classWorkerURL),
coreURL: toAbsoluteURL(coreURL),
wasmURL: toAbsoluteURL(wasmURL)
}]
}
async function loadFFmpegRuntime(FFmpeg, fetchFile) {
let lastError = null
for (const loadConfig of getLoadConfigs()) {
const ffmpeg = new FFmpeg()
ffmpeg.on('progress', ({ progress }) => {
if (typeof progressHandler === 'function') {
progressHandler(Math.max(0, Math.min(1, progress || 0)))
}
})
try {
await ffmpeg.load(loadConfig)
return { ffmpeg, fetchFile }
} catch (error) {
lastError = error
}
}
throw lastError || new Error('FFmpeg 初始化失败')
}
async function getFFmpegInstance() {
if (!ffmpegInstancePromise) {
ffmpegInstancePromise = Promise.all([
import('@ffmpeg/ffmpeg'),
import('@ffmpeg/util')
]).then(([{ FFmpeg }, { fetchFile }]) => loadFFmpegRuntime(FFmpeg, fetchFile))
}
return ffmpegInstancePromise
}
function buildMp4Command(inputName, outputName, options = {}) {
const startTime = Math.max(0, Number(options.startTime) || 0)
const endTime = Number.isFinite(options.endTime) ? Number(options.endTime) : null
const duration = endTime === null ? null : Math.max(0.05, endTime - startTime)
const args = []
if (startTime > 0) {
args.push('-ss', formatFfmpegTime(startTime))
}
args.push('-i', inputName)
if (duration !== null) {
args.push('-t', formatFfmpegTime(duration))
}
if (options.preferEncoding !== false) {
args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p')
if (options.muteAudio) {
args.push('-an')
} else {
args.push('-c:a', 'aac')
}
} else if (options.muteAudio) {
args.push('-an')
}
args.push('-movflags', '+faststart', '-y', outputName)
return args
}
async function executePreferredMp4Command(ffmpeg, inputName, outputName, options = {}) {
try {
await ffmpeg.exec(buildMp4Command(inputName, outputName, {
...options,
preferEncoding: true
}))
} catch {
await ffmpeg.exec(buildMp4Command(inputName, outputName, {
...options,
preferEncoding: false
}))
}
}
async function runMp4Job(blob, fileName, options = {}) {
const { ffmpeg, fetchFile } = await getFFmpegInstance()
const ext = getFileExtension(fileName) || 'bin'
const inputName = `input-${Date.now()}.${ext}`
const outputName = `output-${Date.now()}.mp4`
progressHandler = typeof options.onProgress === 'function' ? options.onProgress : null
try {
await ffmpeg.writeFile(inputName, await fetchFile(blob))
await executePreferredMp4Command(ffmpeg, inputName, outputName, options)
const data = await ffmpeg.readFile(outputName)
return new Blob([data.buffer], { type: 'video/mp4' })
} finally {
progressHandler = null
await ffmpeg.deleteFile?.(inputName)
await ffmpeg.deleteFile?.(outputName)
}
}
export function isVideoFile(node) {
const ext = getFileExtension(node?.name)
const mime = getNodeMimeType(node)
return mime.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)
}
export function canPlayVideoNatively(node) {
if (!isVideoFile(node)) return false
const ext = getFileExtension(node?.name)
if (FORCED_TRANSCODE_EXTENSIONS.has(ext)) return false
const mime = getNodeMimeType(node) || VIDEO_MIME_BY_EXTENSION[ext] || ''
if (!mime) return ['mp4', 'ogv', 'ogg', 'webm'].includes(ext)
const video = createVideoProbe()
if (!video) return ['mp4', 'ogv', 'ogg', 'webm'].includes(ext)
return video.canPlayType(mime) !== ''
}
export async function transcodeVideoToMp4(blob, fileName, options = {}) {
const run = async () => {
try {
return await runMp4Job(blob, fileName, options)
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: '浏览器内视频转换失败'
throw new Error(message)
}
}
const result = transcodeQueue.then(run, run)
transcodeQueue = result.then(() => undefined, () => undefined)
return result
}
export async function editVideoBlob(blob, fileName, options = {}) {
const run = async () => {
try {
return await runMp4Job(blob, fileName, options)
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: '浏览器内视频编辑失败'
throw new Error(message)
}
}
const result = transcodeQueue.then(run, run)
transcodeQueue = result.then(() => undefined, () => undefined)
return result
}
-9
View File
@@ -1,17 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const crossOriginIsolationHeaders = {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin'
}
export default defineConfig({
plugins: [vue()],
server: {
host: true,
port: 5173,
headers: crossOriginIsolationHeaders,
proxy: {
'/v1': {
target: 'https://api.imageteach.tech:8002',
@@ -19,9 +13,6 @@ export default defineConfig({
}
}
},
preview: {
headers: crossOriginIsolationHeaders
},
build: {
rollupOptions: {
output: {