feat: enhance Milkdown editor and file system functionality

- Normalize line endings in Markdown export for DOCX files.
- Improve selection serialization to Markdown with better handling of empty documents.
- Add a new `updateFile` function to the file system for updating file properties.
- Introduce video transcoding capabilities using FFmpeg, supporting various video formats.
- Update AGENTS.md for clearer plugin structure and responsibilities.
- Add scoped styles for TreeNodeItem component to improve UI consistency.
- Implement cross-origin isolation headers in Vite configuration for enhanced security.
- Remove obsolete test_cross.py file.
This commit is contained in:
2026-05-01 20:55:02 +08:00
parent 52ade88840
commit 70152c61b1
43 changed files with 3911 additions and 1373 deletions

View File

@@ -2,6 +2,8 @@
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 props = defineProps({
@@ -9,10 +11,12 @@ const props = defineProps({
breadcrumb: { type: Array, default: () => [] },
rootNodes: { type: Array, default: () => [] },
getFileIcon: { type: Function, default: () => 'file' },
getFileBlob: { type: Function, default: () => null }
getFileBlob: { type: Function, default: () => null },
updateFile: { type: Function, default: null },
showSidebarToggle: { type: Boolean, default: false }
})
const emit = defineEmits(['navigate'])
const emit = defineEmits(['navigate', 'toggle-sidebar'])
const md = new MarkdownIt({
html: false,
@@ -20,7 +24,27 @@ const md = new MarkdownIt({
linkify: true
})
const objectUrl = ref('')
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')
@@ -38,6 +62,7 @@ const isPdf = computed(() => {
const mime = String(props.node?.mimeType || '')
return mime === 'application/pdf' || fileExt.value === 'pdf'
})
const isVideo = computed(() => isVideoFile(props.node))
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)
@@ -68,18 +93,161 @@ const locLabel = computed(() => {
const count = lineCount.value
return count ? `${count}` : '二进制文件'
})
const objectUrl = computed(() => editedVideoUrl.value || 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,
(blob) => {
if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
objectUrl.value = blob instanceof Blob ? URL.createObjectURL(blob) : ''
[fileBlob, () => props.node?.id],
async ([blob]) => {
const currentTaskId = ++previewTaskId
clearBasePreview()
resetVideoPreviewState()
resetVideoEditState()
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
}
}
},
{ immediate: true }
)
watch(
() => props.node?.id,
() => {
isEditingImage.value = false
isSavingImage.value = false
imageEditorError.value = ''
}
)
onBeforeUnmount(() => {
if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
previewTaskId += 1
clearBasePreview()
clearEditedVideoResult()
})
function formatBytes(bytes = 0) {
@@ -103,6 +271,166 @@ function navigateTo(id) {
emit('navigate', id)
}
function toggleSidebar() {
emit('toggle-sidebar')
}
function getImageExportFormat() {
if (fileExt.value === 'jpg' || fileExt.value === 'jpeg') return 'jpeg'
if (fileExt.value === 'webp') return 'webp'
return 'png'
}
function startImageEditing() {
if (!canEditImage.value || !objectUrl.value || isSavingImage.value) return
imageEditorError.value = ''
isEditingImage.value = true
}
function cancelImageEditing() {
if (isSavingImage.value) return
imageEditorError.value = ''
isEditingImage.value = false
}
function handleImageEditorError(message) {
imageEditorError.value = String(message || '图片编辑器加载失败')
}
async function saveImageEdits() {
if (!props.node?.id || typeof props.updateFile !== 'function' || !imageEditorRef.value) return
isSavingImage.value = true
imageEditorError.value = ''
try {
const editedBlob = await imageEditorRef.value.exportImageBlob({
format: getImageExportFormat(),
quality: 0.92
})
const updated = await props.updateFile(props.node.id, editedBlob, {
mimeType: editedBlob.type || props.node?.mimeType || '',
storageKind: 'blob',
previewText: '',
isTruncatedPreview: false
})
if (updated === false) {
throw new Error('保存图片失败')
}
isEditingImage.value = false
} catch (error) {
imageEditorError.value = error instanceof Error && error.message
? error.message
: '保存图片失败'
} finally {
isSavingImage.value = false
}
}
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)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
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'))
}
async function copyText() {
if (!previewText.value) return
try {
@@ -120,13 +448,7 @@ function openRaw() {
function downloadFile() {
const blob = fileBlob.value
if (!blob) return
const url = objectUrl.value || URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = props.node?.name || 'download'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
downloadBlob(blob, props.node?.name || 'download')
}
</script>
@@ -182,35 +504,58 @@ function downloadFile() {
<template v-else-if="node">
<div class="github-file-header">
<div class="file-path-bar">
<div class="file-path">
<span v-for="(item, index) in breadcrumb" :key="item.id || item.name" class="file-path-item">
<button
v-if="index < breadcrumb.length - 1"
type="button"
class="breadcrumb-link"
@click="navigateTo(item.id)"
>
{{ item.name }}
</button>
<span v-else class="breadcrumb-current">{{ item.name }}</span>
<span v-if="index < breadcrumb.length - 1" class="path-sep">/</span>
</span>
</div>
<button class="top-link" type="button">Top</button>
</div>
<div class="file-header-row">
<button
v-if="showSidebarToggle"
class="sidebar-toggle-btn"
type="button"
title="切换左侧栏"
@click="toggleSidebar"
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M2.75 2A1.75 1.75 0 001 3.75v8.5C1 13.216 1.784 14 2.75 14h10.5A1.75 1.75 0 0015 12.25v-8.5A1.75 1.75 0 0013.25 2H2.75zm0 1.5h2.5v9h-2.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25zm4 9v-9h6.5a.25.25 0 01.25.25v8.5a.25.25 0 01-.25.25h-6.5z"/></svg>
</button>
<div class="file-meta-row">
<div class="meta-tabs">
<button class="meta-tab active" type="button">{{ isImage || isPdf ? 'Preview' : 'Code' }}</button>
<button class="meta-tab" type="button" disabled>Blame</button>
<span class="file-meta">{{ locLabel }}</span>
<span class="file-meta">{{ fileSizeLabel }}</span>
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</span>
<div class="file-header-main">
<div class="file-path">
<button type="button" class="breadcrumb-link" @click="navigateTo(null)">workspace</button>
<template v-for="(item, index) in breadcrumb" :key="item.id || item.name">
<span class="path-sep">/</span>
<button
v-if="index < breadcrumb.length - 1"
type="button"
class="breadcrumb-link"
@click="navigateTo(item.id)"
>
{{ item.name }}
</button>
<span v-else class="breadcrumb-current">{{ item.name }}</span>
</template>
</div>
<div class="file-header-meta">
<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>
</div>
</div>
<div class="file-actions">
<button class="action-btn" type="button" @click="openRaw" :disabled="!objectUrl">Raw</button>
<button
v-if="canEditImage && !isEditingImage"
class="action-btn"
type="button"
@click="startImageEditing"
:disabled="!objectUrl"
>
编辑图片
</button>
<template v-else-if="canEditImage && isEditingImage">
<button class="action-btn" type="button" @click="cancelImageEditing" :disabled="isSavingImage">取消</button>
<button class="action-btn primary-btn" type="button" @click="saveImageEdits" :disabled="isSavingImage">{{ isSavingImage ? '保存中' : '保存编辑' }}</button>
</template>
<button class="action-btn" type="button" @click="copyText" :disabled="!previewText">复制</button>
<button class="action-btn" type="button" @click="downloadFile" :disabled="!fileBlob">下载</button>
</div>
@@ -231,11 +576,101 @@ function downloadFile() {
</div>
<div v-else-if="isImage && objectUrl" class="content-preview">
<div class="preview-surface image-surface">
<div class="preview-surface image-surface" :class="{ 'image-editor-active': isEditingImage }">
<div v-if="imageEditorError" class="image-inline-error">{{ imageEditorError }}</div>
<ImageEditorComponent
v-if="isEditingImage"
ref="imageEditorRef"
:image-url="objectUrl"
:image-name="node.name"
@error="handleImageEditorError"
/>
<img :src="objectUrl" :alt="node.name" />
</div>
</div>
<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">
<h3>视频预览暂时不可用</h3>
<p>{{ videoPreviewError }}</p>
<p>原始文件仍保存在浏览器本地你可以继续下载后使用本地播放器打开</p>
</div>
<video
v-else-if="objectUrl"
ref="videoElement"
class="video-player"
controls
playsinline
:src="objectUrl"
@loadedmetadata="handleVideoLoadedMetadata"
@timeupdate="handleVideoTimeUpdate"
></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>
<div v-else-if="isPdf && objectUrl" class="content-preview">
<iframe class="pdf-frame" :src="objectUrl" :title="node.name"></iframe>
</div>
@@ -411,17 +846,39 @@ function downloadFile() {
background: var(--github-bg);
}
.file-path-bar,
.file-meta-row {
.file-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
flex-wrap: wrap;
}
.file-path-bar {
border-bottom: 1px solid var(--github-border);
.sidebar-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--github-border);
border-radius: 8px;
background: var(--github-bg);
color: var(--github-text-secondary);
cursor: pointer;
}
.sidebar-toggle-btn:hover {
background: var(--github-hover);
}
.file-header-main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
flex: 1;
flex-wrap: wrap;
}
.file-path {
@@ -432,12 +889,6 @@ function downloadFile() {
flex-wrap: wrap;
}
.file-path-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.breadcrumb-link {
border: none;
background: transparent;
@@ -458,41 +909,18 @@ function downloadFile() {
font-size: 13px;
}
.top-link {
border: none;
background: transparent;
color: var(--github-text-secondary);
font-weight: 600;
}
.meta-tabs {
.file-header-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.meta-tab {
height: 32px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 8px;
background: var(--github-hover);
color: var(--github-text);
font-weight: 600;
}
.meta-tab[disabled] {
opacity: 0.56;
}
.meta-tab.active {
background: var(--github-bg);
}
.file-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}
.action-btn {
@@ -611,6 +1039,243 @@ function downloadFile() {
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
.image-surface.image-editor-active {
display: block;
padding: 0;
background: transparent;
border: none;
}
.image-surface.image-editor-active img {
display: none;
}
.image-inline-error {
margin-bottom: 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-surface {
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 20px;
min-height: 72vh;
padding: 24px;
background:
radial-gradient(circle at top, rgba(9, 105, 218, 0.08), transparent 38%),
linear-gradient(180deg, rgba(13, 17, 23, 0.04), rgba(13, 17, 23, 0.08));
}
.video-player {
align-self: center;
width: min(1100px, 100%);
max-height: 78vh;
border-radius: 14px;
background: #000;
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;
color: #fff;
}
.primary-btn:hover:enabled {
background: #0859ba;
}
.video-state-card {
width: min(520px, 100%);
padding: 24px;
border: 1px solid var(--github-border);
border-radius: 16px;
background: color-mix(in srgb, var(--github-bg) 92%, white 8%);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
text-align: center;
}
.video-state-card h3 {
margin: 0 0 10px;
}
.video-state-card p {
margin: 0;
color: var(--github-text-secondary);
line-height: 1.7;
}
.video-state-card strong {
display: block;
margin-top: 12px;
color: var(--github-text);
}
.video-state-error {
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;
@@ -694,6 +1359,7 @@ function downloadFile() {
.icon-sql::before { content: 'SQL'; font-size: 8px; font-weight: 700; color: #8250df; }
.icon-image::before { content: '🖼'; font-size: 13px; }
.icon-pdf::before { content: 'PDF'; font-size: 8px; font-weight: 700; color: #cf222e; }
.icon-video::before { content: '▶'; font-size: 11px; font-weight: 700; color: #0969da; }
.icon-word::before { content: 'DOC'; font-size: 7px; font-weight: 700; color: #0969da; }
.icon-ppt::before { content: 'PPT'; font-size: 7px; font-weight: 700; color: #bc4c00; }
.icon-excel::before { content: 'XLS'; font-size: 7px; font-weight: 700; color: #1f883d; }
@@ -701,13 +1367,21 @@ function downloadFile() {
.icon-text::before { content: 'TXT'; font-size: 8px; font-weight: 700; color: #6e7781; }
@media (max-width: 960px) {
.file-path-bar,
.file-meta-row,
.file-header-row,
.directory-card-header {
flex-direction: column;
align-items: flex-start;
}
.file-header-main,
.file-actions {
width: 100%;
}
.file-actions {
margin-left: 0;
}
.directory-row {
grid-template-columns: minmax(0, 1fr);
padding-top: 10px;