refactor(ui): add context menu and file content viewer to Docs view

Introduce ContextMenu.vue and FileContent.vue components for interactive file operations
and file preview.

Update FileTree to support root drop, integrate the new components into DocsView,
and refresh i18n strings for file actions.

Refactor MilkdownEditor to embed TTS menu and player.
This commit is contained in:
2026-04-05 23:30:01 +08:00
parent 01b132266a
commit c70cb2a9f0
6 changed files with 1258 additions and 21 deletions
+164
View File
@@ -0,0 +1,164 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
node: { type: Object, default: null },
canPaste: { type: Boolean, default: false }
})
const emit = defineEmits(['close', 'rename', 'delete', 'copy', 'cut', 'paste', 'new-file', 'new-folder'])
const menuRef = ref(null)
const menuStyle = ref({})
function handleClickOutside(event) {
if (menuRef.value && !menuRef.value.contains(event.target)) {
emit('close')
}
}
function handleKeydown(event) {
if (event.key === 'Escape') {
emit('close')
}
}
watch(() => props.visible, async (val) => {
if (val) {
await nextTick()
const menu = menuRef.value
if (menu) {
const rect = menu.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
menuStyle.value = {
left: props.x + rect.width > vw ? vw - rect.width - 8 : props.x,
top: props.y + rect.height > vh ? vh - rect.height - 8 : props.y
}
}
}
})
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<Teleport to="body">
<div
v-if="visible"
ref="menuRef"
class="context-menu"
:style="{ left: `${menuStyle.left || x}px`, top: `${menuStyle.top || y}px` }"
>
<template v-if="node">
<button v-if="node.type === 'folder'" class="context-menu-item" @click="emit('new-file', node.id)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H3.75zM3 1.75C3 .784 3.784 0 4.75 0h5.339c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113 16H4.75A1.75 1.75 0 013 14.25V1.75z"/><path d="M8.5 4V1.5H10a.5.5 0 01.5.5v1.5a.5.5 0 01-.5.5H9a.5.5 0 01-.5-.5zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
新建文件
</button>
<button v-if="node.type === 'folder'" class="context-menu-item" @click="emit('new-folder', node.id)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M.5 2.5A1.5 1.5 0 012 1h3.5a.5.5 0 01.354.146l1.5 1.5a.5.5 0 00.354.146H13a1.5 1.5 0 011.5 1.5v7.5a1.5 1.5 0 01-1.5 1.5H2a1.5 1.5 0 01-1.5-1.5v-7.5zM6 2v1.5h4.5V2H6zm-2 5a.5.5 0 01.5-.5h5a.5.5 0 010 1h-5a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h5a.5.5 0 000-1h-5z"/></svg>
新建文件夹
</button>
<div v-if="node.type === 'folder'" class="context-menu-divider"></div>
<button class="context-menu-item" @click="emit('copy', node.id)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25zM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25z"/></svg>
复制
</button>
<button class="context-menu-item" @click="emit('cut', node.id)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.455.752a1.523 1.523 0 012.173.043l2.84 3.076a3.052 3.052 0 01.524.669h3.258a.75.75 0 01.643 1.137l-2.55 4.25a.75.75 0 01-1.286-.784L11.5 6.75h-2.5a3.052 3.052 0 01-.524.669L5.632 10.49a1.523 1.523 0 01-2.173.043l-.93-.93a.75.75 0 111.06-1.06l.93.93 2.845-3.076a1.55 1.55 0 000-2.134L4.52 1.683l-.93.93a.75.75 0 01-1.06-1.06l.93-.93.995.995z"/></svg>
剪切
</button>
<button v-if="canPaste" class="context-menu-item" @click="emit('paste', node.type === 'folder' ? node.id : null)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v.59c0 .396.316.717.707.717h5.586c.39 0 .707-.32.707-.716v-.591a.25.25 0 00-.25-.25H4.75zm6.543-.75a1.75 1.75 0 011.75 1.75v.59c0 .396-.107.767-.293 1.086l1.293 1.293a.75.75 0 010 1.061l-1.293 1.293c.186.32.293.69.293 1.087v.59a1.75 1.75 0 01-1.75 1.75H4.75a1.75 1.75 0 01-1.75-1.75v-.59c0-.396.107-.767.293-1.087L2 5.53a.75.75 0 010-1.06l1.293-1.294A2.048 2.048 0 013 2.09v-.59A1.75 1.75 0 014.75 0h6.543zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
粘贴
</button>
<div class="context-menu-divider"></div>
<button class="context-menu-item danger" @click="emit('delete', node.id)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.41 15h5.18a1.75 1.75 0 001.746-1.578l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.227H5.41a.25.25 0 01-.249-.227l-.66-6.6z"/></svg>
删除
</button>
</template>
<template v-else>
<button class="context-menu-item" @click="emit('new-file', null)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H3.75zM3 1.75C3 .784 3.784 0 4.75 0h5.339c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113 16H4.75A1.75 1.75 0 013 14.25V1.75z"/><path d="M8.5 4V1.5H10a.5.5 0 01.5.5v1.5a.5.5 0 01-.5.5H9a.5.5 0 01-.5-.5zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
新建文件
</button>
<button class="context-menu-item" @click="emit('new-folder', null)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M.5 2.5A1.5 1.5 0 012 1h3.5a.5.5 0 01.354.146l1.5 1.5a.5.5 0 00.354.146H13a1.5 1.5 0 011.5 1.5v7.5a1.5 1.5 0 01-1.5 1.5H2a1.5 1.5 0 01-1.5-1.5v-7.5zM6 2v1.5h4.5V2H6zm-2 5a.5.5 0 01.5-.5h5a.5.5 0 010 1h-5a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h5a.5.5 0 000-1h-5z"/></svg>
新建文件夹
</button>
<button v-if="canPaste" class="context-menu-item" @click="emit('paste', null)">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v.59c0 .396.316.717.707.717h5.586c.39 0 .707-.32.707-.716v-.591a.25.25 0 00-.25-.25H4.75zm6.543-.75a1.75 1.75 0 011.75 1.75v.59c0 .396-.107.767-.293 1.086l1.293 1.293a.75.75 0 010 1.061l-1.293 1.293c.186.32.293.69.293 1.087v.59a1.75 1.75 0 01-1.75 1.75H4.75a1.75 1.75 0 01-1.75-1.75v-.59c0-.396.107-.767.293-1.087L2 5.53a.75.75 0 010-1.06l1.293-1.294A2.048 2.048 0 013 2.09v-.59A1.75 1.75 0 014.75 0h6.543zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
粘贴
</button>
</template>
</div>
</Teleport>
</template>
<style scoped>
.context-menu {
position: fixed;
z-index: 100000;
min-width: 200px;
background: var(--panel-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--panel-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: 4px;
animation: contextMenuIn 0.12s ease-out;
}
@keyframes contextMenuIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
color: var(--app-text);
font-size: 13px;
cursor: pointer;
border-radius: 4px;
text-align: left;
}
.context-menu-item:hover {
background: var(--focus-ring);
color: #fff;
}
.context-menu-item.danger {
color: var(--danger-text);
}
.context-menu-item.danger:hover {
background: var(--danger-text);
color: #fff;
}
.context-menu-divider {
height: 1px;
background: var(--panel-border);
margin: 4px 0;
}
</style>
+272
View File
@@ -0,0 +1,272 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: { type: Object, default: null },
breadcrumb: { type: Array, default: () => [] }
})
const emit = defineEmits(['navigate'])
const fileExt = computed(() => {
if (!props.node || props.node.type !== 'file') return ''
const parts = props.node.name.split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
})
const isMarkdown = computed(() => {
return fileExt.value === 'md' || fileExt.value === 'markdown'
})
const isText = computed(() => {
const textExts = ['txt', 'json', 'js', 'ts', 'css', 'html', 'py', 'vue', 'xml', 'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg', 'conf', 'sh', 'bat']
return textExts.includes(fileExt.value) || isMarkdown.value
})
function navigateTo(id) {
emit('navigate', id)
}
</script>
<template>
<div class="file-content">
<div v-if="breadcrumb.length > 0" class="breadcrumb">
<template v-for="(item, index) in breadcrumb" :key="item.id">
<span
v-if="item.type === 'folder'"
class="breadcrumb-item"
@click="navigateTo(item.id)"
>{{ item.name }}</span>
<span v-else class="breadcrumb-item breadcrumb-current">{{ item.name }}</span>
<span v-if="index < breadcrumb.length - 1" class="breadcrumb-sep">/</span>
</template>
</div>
<div v-if="!node" class="content-empty">
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<p>选择一个文件以查看内容</p>
</div>
<div v-else-if="node.type === 'folder'" class="content-folder">
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<h3>{{ node.name }}</h3>
<p>包含 {{ (node.children || []).length }} 个项目</p>
</div>
<div v-else-if="isMarkdown" class="content-markdown">
<div class="markdown-body" v-html="renderMarkdown(node.content || '')"></div>
</div>
<div v-else-if="isText" class="content-text">
<pre class="text-content">{{ node.content || '' }}</pre>
</div>
<div v-else class="content-unsupported">
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<p>暂不支持预览此文件类型</p>
<p class="file-ext">.{{ fileExt }}</p>
</div>
</div>
</template>
<script>
function renderMarkdown(text) {
if (!text) return ''
let html = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^\> (.+)$/gm, '<blockquote>$1</blockquote>')
.replace(/^\- (.+)$/gm, '<li>$1</li>')
.replace(/^(\d+)\. (.+)$/gm, '<li>$1. $2</li>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
return `<p>${html}</p>`
}
</script>
<style scoped>
.file-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
font-size: 13px;
color: var(--muted-text);
border-bottom: 1px solid var(--panel-border);
flex-shrink: 0;
}
.breadcrumb-item {
cursor: pointer;
color: var(--focus-ring);
}
.breadcrumb-item:hover {
text-decoration: underline;
}
.breadcrumb-current {
color: var(--app-text);
cursor: default;
}
.breadcrumb-current:hover {
text-decoration: none;
}
.breadcrumb-sep {
color: var(--muted-text);
}
.content-empty,
.content-folder,
.content-unsupported {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--muted-text);
padding: 32px;
}
.content-empty svg,
.content-folder svg,
.content-unsupported svg {
opacity: 0.4;
}
.content-folder h3 {
margin: 0;
font-size: 1.25rem;
color: var(--app-text);
}
.content-folder p,
.content-empty p {
margin: 0;
font-size: 0.9rem;
}
.file-ext {
font-family: monospace;
font-size: 0.85rem;
background: var(--ghost-code-bg);
padding: 4px 8px;
border-radius: 4px;
}
.content-markdown {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.markdown-body {
max-width: 800px;
margin: 0 auto;
line-height: 1.7;
color: var(--app-text);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
margin-top: 24px;
margin-bottom: 12px;
font-weight: 600;
line-height: 1.3;
}
.markdown-body h1 { font-size: 1.75rem; border-bottom: 1px solid var(--panel-border); padding-bottom: 8px; }
.markdown-body h2 { font-size: 1.4rem; border-bottom: 1px solid var(--panel-border); padding-bottom: 6px; }
.markdown-body h3 { font-size: 1.15rem; }
.markdown-body strong { font-weight: 600; }
.markdown-body em { font-style: italic; }
.markdown-body code {
background: var(--code-inline-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
color: var(--code-text);
}
.markdown-body blockquote {
border-left: 3px solid var(--focus-ring);
padding-left: 16px;
margin: 12px 0;
color: var(--muted-text);
}
.markdown-body li {
margin-left: 20px;
list-style: disc;
}
.content-text {
flex: 1;
overflow: auto;
padding: 16px;
}
.text-content {
margin: 0;
padding: 16px;
background: var(--code-block-bg);
border: 1px solid var(--code-block-border);
border-radius: 8px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--code-text);
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
max-width: 100%;
}
.content-markdown::-webkit-scrollbar,
.content-text::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.content-markdown::-webkit-scrollbar-thumb,
.content-text::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
.content-markdown::-webkit-scrollbar-thumb:hover,
.content-text::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
</style>
+10 -2
View File
@@ -79,6 +79,14 @@ function handleDrop(event, targetNode) {
}
}
function handleDropRoot(event) {
event.preventDefault()
const draggedId = event.dataTransfer.getData('text/plain')
if (draggedId) {
emit('drop', draggedId, null)
}
}
function isClipped(id) {
return props.clipboard && props.clipboard.node && props.clipboard.node.id === id
}
@@ -104,8 +112,8 @@ function getIconClass(type, name) {
</div>
<div
class="tree-content"
@drop.self="handleDrop($event, null)"
@dragover.self="(e) => e.preventDefault()"
@drop="handleDropRoot"
@dragover="(e) => e.preventDefault()"
>
<template v-if="nodes.length === 0">
<div class="tree-empty">
+169
View File
@@ -153,6 +153,22 @@
<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>
@@ -197,6 +213,19 @@ 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')
@@ -476,6 +505,122 @@ 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 = []
doc.nodesBetween(from, to, (node, pos) => {
if (SKIP_TTS_TYPES.has(node.type.name)) {
return false
}
if (node.type.name === DOC_BLOCK_NODE_TYPE) {
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('\n').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)
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) => {
@@ -730,6 +875,20 @@ crepe = new Crepe({
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()
})
@@ -1073,9 +1232,19 @@ for (const url of Array.from(objectUrls)) {
})
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>
+180 -6
View File
@@ -60,7 +60,36 @@ export const translations = {
editor: 'Editor',
docs: 'Docs',
docsManagement: 'Document Management',
docsEmptyDesc: 'Document management interface is under development...'
docsEmptyDesc: 'Document management interface is under development...',
files: 'Files',
noFiles: '暂无文件',
newFile: '新建文件',
newFolder: '新建文件夹',
untitledFile: 'untitled.md',
untitledFolder: '新建文件夹',
rename: '重命名',
delete: '删除',
copy: '复制',
cut: '剪切',
paste: '粘贴',
confirmDelete: '确认删除',
confirmDeleteDesc: '确定要删除',
confirmDeleteFolderDesc: '此操作将删除文件夹内的所有内容。',
confirmDeleteFileDesc: '此操作不可撤销。',
cancel: '取消',
rootDir: '根目录',
expandSidebar: '展开侧边栏',
collapseSidebar: '收起侧边栏',
fileLimitReached: '文件数量已达上限',
folderLimitReached: '文件夹数量已达上限',
fileSizeLimit: '文件大小不能超过 50MB',
storageError: '存储空间不足',
selectFileToView: '选择一个文件以查看内容',
folderContains: '包含',
items: '个项目',
unsupportedPreview: '暂不支持预览此文件类型',
fileNamePlaceholder: '文件名.md',
folderNamePlaceholder: '文件夹名'
},
zh: {
settings: '设置',
@@ -123,7 +152,36 @@ export const translations = {
editor: '编辑器',
docs: '文档',
docsManagement: '文档管理',
docsEmptyDesc: '文档管理界面开发中...'
docsEmptyDesc: '文档管理界面开发中...',
files: '文件',
noFiles: '暂无文件',
newFile: '新建文件',
newFolder: '新建文件夹',
untitledFile: '未命名.md',
untitledFolder: '新建文件夹',
rename: '重命名',
delete: '删除',
copy: '复制',
cut: '剪切',
paste: '粘贴',
confirmDelete: '确认删除',
confirmDeleteDesc: '确定要删除',
confirmDeleteFolderDesc: '此操作将删除文件夹内的所有内容。',
confirmDeleteFileDesc: '此操作不可撤销。',
cancel: '取消',
rootDir: '根目录',
expandSidebar: '展开侧边栏',
collapseSidebar: '收起侧边栏',
fileLimitReached: '文件数量已达上限',
folderLimitReached: '文件夹数量已达上限',
fileSizeLimit: '文件大小不能超过 50MB',
storageError: '存储空间不足',
selectFileToView: '选择一个文件以查看内容',
folderContains: '包含',
items: '个项目',
unsupportedPreview: '暂不支持预览此文件类型',
fileNamePlaceholder: '文件名.md',
folderNamePlaceholder: '文件夹名'
},
ja: {
settings: '設定',
@@ -181,7 +239,36 @@ export const translations = {
editor: 'エディター',
docs: 'ドキュメント',
docsManagement: 'ドキュメント管理',
docsEmptyDesc: 'ドキュメント管理画面は開発中です...'
docsEmptyDesc: 'ドキュメント管理画面は開発中です...',
files: 'ファイル',
noFiles: 'ファイルはありません',
newFile: '新規ファイル',
newFolder: '新規フォルダー',
untitledFile: '無題.md',
untitledFolder: '新しいフォルダー',
rename: '名前を変更',
delete: '削除',
copy: 'コピー',
cut: '切り取り',
paste: '貼り付け',
confirmDelete: '削除の確認',
confirmDeleteDesc: '本当に削除しますか',
confirmDeleteFolderDesc: 'フォルダー内のすべてのコンテンツが削除されます。',
confirmDeleteFileDesc: 'この操作は元に戻せません。',
cancel: 'キャンセル',
rootDir: 'ルートディレクトリ',
expandSidebar: 'サイドバーを展開',
collapseSidebar: 'サイドバーを折りたたむ',
fileLimitReached: 'ファイル数の上限に達しました',
folderLimitReached: 'フォルダー数の上限に達しました',
fileSizeLimit: 'ファイルサイズは50MBを超えられません',
storageError: 'ストレージが不足しています',
selectFileToView: 'ファイルを選択して内容を表示',
folderContains: '含む',
items: '項目',
unsupportedPreview: 'このファイルタイプはプレビューに対応していません',
fileNamePlaceholder: 'ファイル名.md',
folderNamePlaceholder: 'フォルダー名'
},
ko: {
settings: '설정',
@@ -236,7 +323,36 @@ export const translations = {
editor: '에디터',
docs: '문서',
docsManagement: '문서 관리',
docsEmptyDesc: '문서 관리 화면은 개발 중입니다...'
docsEmptyDesc: '문서 관리 화면은 개발 중입니다...',
files: '파일',
noFiles: '파일이 없습니다',
newFile: '새 파일',
newFolder: '새 폴더',
untitledFile: '제목 없음.md',
untitledFolder: '새 폴더',
rename: '이름 변경',
delete: '삭제',
copy: '복사',
cut: '잘라내기',
paste: '붙여넣기',
confirmDelete: '삭제 확인',
confirmDeleteDesc: '정말 삭제하시겠습니까',
confirmDeleteFolderDesc: '폴더의 모든 콘텐츠가 삭제됩니다.',
confirmDeleteFileDesc: '이 작업은 취소할 수 없습니다.',
cancel: '취소',
rootDir: '루트 디렉토리',
expandSidebar: '사이드바 펼치기',
collapseSidebar: '사이드바 접기',
fileLimitReached: '파일 수上限에 도달했습니다',
folderLimitReached: '폴더 수上限에 도달했습니다',
fileSizeLimit: '파일 크기는 50MB를 초과할 수 없습니다',
storageError: '저장 공간이 부족합니다',
selectFileToView: '파일을 선택하여 내용 보기',
folderContains: '포함',
items: '항목',
unsupportedPreview: '이 파일 유형은 미리보기를 지원하지 않습니다',
fileNamePlaceholder: '파일명.md',
folderNamePlaceholder: '폴더명'
},
de: {
settings: 'Einstellungen',
@@ -291,7 +407,36 @@ export const translations = {
editor: 'Editor',
docs: 'Dokumente',
docsManagement: 'Dokumentenverwaltung',
docsEmptyDesc: 'Die Dokumentenverwaltung ist in Entwicklung...'
docsEmptyDesc: 'Die Dokumentenverwaltung ist in Entwicklung...',
files: 'Dateien',
noFiles: 'Keine Dateien',
newFile: 'Neue Datei',
newFolder: 'Neuer Ordner',
untitledFile: 'Unbenannt.md',
untitledFolder: 'Neuer Ordner',
rename: 'Umbenennen',
delete: 'Löschen',
copy: 'Kopieren',
cut: 'Ausschneiden',
paste: 'Einfügen',
confirmDelete: 'Löschen bestätigen',
confirmDeleteDesc: 'Möchten Sie wirklich löschen',
confirmDeleteFolderDesc: 'Dies entfernt alle Inhalte im Ordner.',
confirmDeleteFileDesc: 'Diese Aktion kann nicht rückgängig gemacht werden.',
cancel: 'Abbrechen',
rootDir: 'Stammverzeichnis',
expandSidebar: 'Seitenleiste erweitern',
collapseSidebar: 'Seitenleiste einklappen',
fileLimitReached: 'Maximale Dateianzahl erreicht',
folderLimitReached: 'Maximale Ordneranzahl erreicht',
fileSizeLimit: 'Dateigröße darf 50MB nicht überschreiten',
storageError: 'Speicherplatz unzureichend',
selectFileToView: 'Datei auswählen zum Anzeigen',
folderContains: 'Enthält',
items: 'Elemente',
unsupportedPreview: 'Dieser Dateityp wird nicht in der Vorschau unterstützt',
fileNamePlaceholder: 'Dateiname.md',
folderNamePlaceholder: 'Ordnername'
},
fr: {
settings: 'Paramètres',
@@ -346,6 +491,35 @@ export const translations = {
editor: 'Éditeur',
docs: 'Documents',
docsManagement: 'Gestion des documents',
docsEmptyDesc: 'L\'interface de gestion des documents est en développement...'
docsEmptyDesc: 'L\'interface de gestion des documents est en développement...',
files: 'Fichiers',
noFiles: 'Aucun fichier',
newFile: 'Nouveau fichier',
newFolder: 'Nouveau dossier',
untitledFile: 'Sans titre.md',
untitledFolder: 'Nouveau dossier',
rename: 'Renommer',
delete: 'Supprimer',
copy: 'Copier',
cut: 'Couper',
paste: 'Coller',
confirmDelete: 'Confirmer la suppression',
confirmDeleteDesc: 'Êtes-vous sûr de vouloir supprimer',
confirmDeleteFolderDesc: 'Cela supprimera tout le contenu du dossier.',
confirmDeleteFileDesc: 'Cette action est irréversible.',
cancel: 'Annuler',
rootDir: 'Répertoire racine',
expandSidebar: 'Développer la barre latérale',
collapseSidebar: 'Réduire la barre latérale',
fileLimitReached: 'Nombre maximum de fichiers atteint',
folderLimitReached: 'Nombre maximum de dossiers atteint',
fileSizeLimit: 'La taille du fichier ne peut pas dépasser 50 Mo',
storageError: 'Espace de stockage insuffisant',
selectFileToView: 'Sélectionnez un fichier pour afficher le contenu',
folderContains: 'Contient',
items: 'éléments',
unsupportedPreview: 'Ce type de fichier n\'est pas pris en charge pour l\'aperçu',
fileNamePlaceholder: 'Nom du fichier.md',
folderNamePlaceholder: 'Nom du dossier'
}
}
+463 -13
View File
@@ -1,40 +1,490 @@
<script setup>
import { useSettingsStore } from '../stores/settings'
import { ref, onMounted, computed } from 'vue'
import { useFileSystem } from '../composables/useFileSystem'
import FileTree from '../components/FileTree.vue'
import FileContent from '../components/FileContent.vue'
import ContextMenu from '../components/ContextMenu.vue'
const settings = useSettingsStore()
const t = (key) => settings.t[key]
const fs = useFileSystem()
const sidebarCollapsed = ref(false)
const confirmDialog = ref(null)
onMounted(() => {
fs.load()
})
const selectedNode = computed(() => fs.getSelectedNode())
const breadcrumb = computed(() => {
if (!fs.selectedId.value) return []
return fs.getBreadcrumbPath(fs.selectedId.value)
})
function handleCreateFile(parentId) {
const name = 'untitled.md'
fs.createFile(parentId, name)
}
function handleCreateFolder(parentId) {
const name = '新建文件夹'
fs.createFolder(parentId, name)
}
function handleRename(id, newName) {
fs.rename(id, newName)
}
function handleDelete(id) {
const node = fs.tree.value.find(n => n.id === id) || findNode(fs.tree.value, id)
if (node) {
confirmDialog.value = {
id,
name: node.name,
type: node.type
}
}
}
function confirmDelete() {
if (confirmDialog.value) {
fs.remove(confirmDialog.value.id)
confirmDialog.value = null
}
}
function cancelDelete() {
confirmDialog.value = null
}
function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return node
if (node.type === 'folder') {
const found = findNode(node.children || [], id)
if (found) return found
}
}
return null
}
function handleContextMenu(x, y, node) {
fs.showContextMenu(x, y, node)
}
function handleDrop(draggedId, targetParentId) {
if (draggedId === targetParentId) return
const draggedNode = findNode(fs.tree.value, draggedId)
if (!draggedNode) return
if (targetParentId && isDescendant(draggedNode, targetParentId)) return
const oldParent = findParent(fs.tree.value, draggedId)
if (oldParent) {
oldParent.children = (oldParent.children || []).filter(c => c.id !== draggedId)
} else {
fs.tree.value = fs.tree.value.filter(n => n.id !== draggedId)
}
draggedNode.parentId = targetParentId || null
if (targetParentId) {
const target = findNode(fs.tree.value, targetParentId)
if (target && target.type === 'folder') {
target.children = target.children || []
target.children.push(draggedNode)
}
} else {
fs.tree.value.push(draggedNode)
}
}
function isDescendant(node, targetId) {
if (node.type !== 'folder') return false
for (const child of (node.children || [])) {
if (child.id === targetId) return true
if (isDescendant(child, targetId)) return true
}
return false
}
function findParent(nodes, id, parent = null) {
for (const node of nodes) {
if (node.id === id) return parent
if (node.type === 'folder') {
const found = findParent(node.children || [], id, node)
if (found !== undefined) return found
}
}
return undefined
}
function handleDragStart(event, id) {
event.dataTransfer.setData('text/plain', id)
}
function handleDragOver(event) {
event.preventDefault()
}
</script>
<template>
<div class="docs-view">
<div class="docs-empty">
<h2>{{ t('docsManagement') || '文档管理' }}</h2>
<p>{{ t('docsEmptyDesc') || '文档管理界面开发中...' }}</p>
<div class="docs-layout">
<div v-show="!sidebarCollapsed" class="docs-sidebar">
<FileTree
:nodes="fs.tree.value"
:selected-id="fs.selectedId.value"
:expanded-ids="fs.expandedIds.value"
:clipboard="fs.clipboard.value"
:get-file-icon="fs.getFileIcon"
@select="fs.select"
@toggle="fs.toggleFolder"
@create-file="handleCreateFile"
@create-folder="handleCreateFolder"
@rename="handleRename"
@remove="handleDelete"
@copy="fs.copy"
@cut="fs.cut"
@paste="fs.paste"
@context-menu="handleContextMenu"
@drop="handleDrop"
@drag-start="handleDragStart"
@drag-over="handleDragOver"
/>
</div>
<div class="docs-main">
<div class="docs-toolbar">
<button class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed" :title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'">
<svg v-if="!sidebarCollapsed" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.5 3.5a.5.5 0 00-.707.707L6.586 7l-2.793 2.793a.5.5 0 10.707.707l3-3a.5.5 0 000-.707l-3-3z"/><path d="M9.5 3.5a.5.5 0 01.707.707L7.414 7l2.793 2.793a.5.5 0 01-.707.707l-3-3a.5.5 0 010-.707l3-3z"/></svg>
<svg v-else viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M11.5 3.5a.5.5 0 01.707.707L9.414 7l2.793 2.793a.5.5 0 01-.707.707l-3-3a.5.5 0 010-.707l3-3z"/><path d="M4.5 3.5a.5.5 0 00-.707.707L6.586 7l-2.793 2.793a.5.5 0 10.707.707l3-3a.5.5 0 000-.707l-3-3z"/></svg>
</button>
<div class="breadcrumb-bar">
<template v-for="(item, index) in breadcrumb" :key="item.id">
<span
v-if="item.type === 'folder' && index < breadcrumb.length - 1"
class="breadcrumb-link"
@click="fs.select(item.id)"
>{{ item.name }}</span>
<span v-else class="breadcrumb-current">{{ item.name }}</span>
<span v-if="index < breadcrumb.length - 1" class="breadcrumb-sep">/</span>
</template>
<span v-if="breadcrumb.length === 0" class="breadcrumb-root">根目录</span>
</div>
<div class="toolbar-actions">
<button v-if="fs.canPaste()" class="toolbar-btn" @click="fs.paste(selectedNode && selectedNode.type === 'folder' ? selectedNode.id : null)" title="粘贴">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v.59c0 .396.316.717.707.717h5.586c.39 0 .707-.32.707-.716v-.591a.25.25 0 00-.25-.25H4.75zm6.543-.75a1.75 1.75 0 011.75 1.75v.59c0 .396-.107.767-.293 1.086l1.293 1.293a.75.75 0 010 1.061l-1.293 1.293c.186.32.293.69.293 1.087v.59a1.75 1.75 0 01-1.75 1.75H4.75a1.75 1.75 0 01-1.75-1.75v-.59c0-.396.107-.767.293-1.087L2 5.53a.75.75 0 010-1.06l1.293-1.294A2.048 2.048 0 013 2.09v-.59A1.75 1.75 0 014.75 0h6.543zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
粘贴
</button>
</div>
</div>
<FileContent
:node="selectedNode"
:breadcrumb="breadcrumb"
@navigate="fs.select"
/>
</div>
</div>
<ContextMenu
:visible="!!fs.contextMenu.value"
:x="fs.contextMenu.value?.x || 0"
:y="fs.contextMenu.value?.y || 0"
:node="fs.contextMenu.value?.node || null"
:can-paste="fs.canPaste()"
@close="fs.hideContextMenu()"
@rename="(id) => { fs.hideContextMenu(); }"
@delete="(id) => { fs.hideContextMenu(); handleDelete(id) }"
@copy="(id) => { fs.hideContextMenu(); fs.copy(id) }"
@cut="(id) => { fs.hideContextMenu(); fs.cut(id) }"
@paste="(parentId) => { fs.hideContextMenu(); fs.paste(parentId) }"
@new-file="(parentId) => { fs.hideContextMenu(); handleCreateFile(parentId) }"
@new-folder="(parentId) => { fs.hideContextMenu(); handleCreateFolder(parentId) }"
/>
<Teleport to="body">
<div v-if="confirmDialog" class="confirm-overlay">
<div class="confirm-dialog">
<div class="confirm-icon">
<svg viewBox="0 0 24 24" width="32" height="32" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h3>确认删除</h3>
<p>确定要删除 <strong>{{ confirmDialog.name }}</strong> 吗?{{ confirmDialog.type === 'folder' ? '此操作将删除文件夹内的所有内容。' : '此操作不可撤销。' }}</p>
<div class="confirm-actions">
<button class="btn-cancel" @click="cancelDelete">取消</button>
<button class="btn-delete" @click="confirmDelete">删除</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="fs.error" class="error-toast">
<div class="error-content">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0a8 8 0 100 16A8 8 0 008 0zm3.78 11.22a.75.75 0 01-1.06 0L8 8.56 5.28 11.28a.75.75 0 01-1.06-1.06L6.94 7.5 4.22 4.78a.75.75 0 011.06-1.06L8 6.44l2.72-2.72a.75.75 0 111.06 1.06L9.06 7.5l2.72 2.72a.75.75 0 010 1.06z"/></svg>
<span>{{ fs.error }}</span>
<button class="error-close" @click="fs.error = null">&times;</button>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.docs-view {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.docs-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.docs-sidebar {
width: 280px;
min-width: 200px;
max-width: 400px;
height: 100%;
border-right: 1px solid var(--panel-border);
flex-shrink: 0;
overflow: hidden;
}
.docs-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.docs-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--panel-border);
min-height: 44px;
flex-shrink: 0;
}
.sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--panel-border);
background: var(--app-bg);
color: var(--muted-text);
cursor: pointer;
border-radius: 6px;
flex-shrink: 0;
}
.docs-empty {
text-align: center;
.sidebar-toggle:hover {
color: var(--app-text);
border-color: var(--focus-ring);
}
.breadcrumb-bar {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
overflow: hidden;
white-space: nowrap;
}
.breadcrumb-link {
color: var(--focus-ring);
cursor: pointer;
}
.breadcrumb-link:hover {
text-decoration: underline;
}
.breadcrumb-current {
color: var(--app-text);
font-weight: 500;
}
.breadcrumb-sep {
color: var(--muted-text);
}
.docs-empty h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
.breadcrumb-root {
color: var(--muted-text);
}
.toolbar-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid var(--panel-border);
background: var(--app-bg);
color: var(--app-text);
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.toolbar-btn:hover {
border-color: var(--focus-ring);
color: var(--focus-ring);
}
.confirm-overlay {
position: fixed;
inset: 0;
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 100001;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.confirm-dialog {
background: var(--panel-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--panel-border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
padding: 24px;
max-width: 400px;
width: 90%;
text-align: center;
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.confirm-icon {
color: var(--danger-text);
margin-bottom: 12px;
}
.confirm-dialog h3 {
margin: 0 0 8px;
font-size: 1.1rem;
}
.confirm-dialog p {
margin: 0 0 20px;
color: var(--muted-text);
font-size: 0.9rem;
line-height: 1.5;
}
.confirm-dialog p strong {
color: var(--app-text);
}
.docs-empty p {
font-size: 1rem;
.confirm-actions {
display: flex;
gap: 8px;
justify-content: center;
}
.btn-cancel,
.btn-delete {
padding: 8px 20px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
border: 1px solid var(--panel-border);
font-weight: 500;
}
.btn-cancel {
background: var(--app-bg);
color: var(--app-text);
}
.btn-cancel:hover {
background: var(--ghost-code-bg);
}
.btn-delete {
background: var(--danger-text);
color: #fff;
border-color: var(--danger-text);
}
.btn-delete:hover {
opacity: 0.9;
}
.error-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 100002;
animation: slideUp 0.2s ease;
}
.error-content {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--danger-text);
color: #fff;
border-radius: 8px;
font-size: 13px;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
.error-close {
background: none;
border: none;
color: #fff;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.error-close:hover {
opacity: 0.8;
}
@media (max-width: 768px) {
.docs-sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 9000;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
}
</style>