feat(frontend): 完善工作区导航与笔记属性并适配手帐主题

This commit is contained in:
2026-09-05 19:11:45 +08:00
parent d5b1050a86
commit a63f6c57e0
13 changed files with 484 additions and 23 deletions
@@ -48,6 +48,67 @@ afterEach(() => {
})
describe('FileTreePanel file switching', () => {
it('switches full-height panels using tabs and preserves the file search', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
await router.push('/workspace')
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
expect(wrapper.get('#workspace-files-panel').isVisible()).toBe(true)
expect(wrapper.get('#workspace-outline-panel').isVisible()).toBe(false)
await wrapper.get('.file-tree-panel').trigger('wheel', { deltaY: -50 })
await wrapper.get('.file-search input').setValue('笔记')
await wrapper.get('#workspace-outline-tab').trigger('click')
expect(wrapper.get('#workspace-files-panel').isVisible()).toBe(false)
expect(wrapper.get('#workspace-outline-panel').isVisible()).toBe(true)
expect(wrapper.get('#workspace-outline-tab').attributes('aria-selected')).toBe('true')
await wrapper.get('#workspace-outline-tab').trigger('keydown', { key: 'ArrowLeft' })
expect(wrapper.get('#workspace-files-panel').isVisible()).toBe(true)
expect((wrapper.get('.file-search input').element as HTMLInputElement).value).toBe('笔记')
})
it('reveals search on upward wheel and filters without changing folder state', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
await router.push('/workspace')
const store = useWorkspaceStore()
await store.openVault('C:/vault')
store.toggleFolder('/数据结构')
wrapper = mount(FileTreePanel, { global: { plugins: [router] } })
expect(wrapper.find('.file-search').exists()).toBe(false)
await wrapper.get('.file-tree-panel').trigger('wheel', { deltaY: -50 })
await wrapper.get('.file-search input').setValue('红黑')
expect(wrapper.findAll('.tree-node').map(node => node.text())).toEqual(['数据结构', '红黑树.md'])
expect(store.fileTree[0]!.is_open).toBe(false)
await wrapper.get('.file-tree-panel').trigger('wheel', { deltaY: 50 })
expect(wrapper.find('.file-search').exists()).toBe(true)
})
it('creates a folder through the file context menu in its containing directory', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
await router.push('/workspace')
await useWorkspaceStore().openVault('C:/vault')
const create = vi.spyOn(workspaceService, 'createFolder').mockResolvedValue({ id: 'new', name: '子目录', path: '/数据结构/子目录', type: 'folder' })
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
await wrapper.findAll('.tree-node').find(node => node.text().includes('红黑树'))!.trigger('contextmenu')
const button = [...document.querySelectorAll<HTMLButtonElement>('.context-menu button')].find(item => item.textContent === '新建文件夹')!
button.click()
await wrapper.vm.$nextTick()
await wrapper.get('.new-item input').setValue('子目录')
await wrapper.get('.new-item').trigger('submit')
expect(create).toHaveBeenCalledWith('/数据结构', '子目录')
})
it('collapses nested headings and requests navigation to a duplicate heading', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
await router.push('/workspace')
const store = useEditorStore()
store.currentFilePath = '/note.md'
store.content = '# 标题\n\n## 子标题\n\n# 标题\n'
wrapper = mount(FileTreePanel, { global: { plugins: [router] } })
await wrapper.get('#workspace-outline-tab').trigger('click')
expect(wrapper.findAll('.outline-title')).toHaveLength(3)
await wrapper.get('.outline-row button[aria-expanded]').trigger('click')
expect(wrapper.findAll('.outline-title')).toHaveLength(2)
await wrapper.findAll('.outline-title')[1]!.trigger('click')
expect(store.headingRequest).toEqual({ index: 2, offset: store.content.lastIndexOf('# 标题'), path: '/note.md' })
})
it('switches both workspace selection and editor content on consecutive clicks', async () => {
const router = createRouter({
history: createMemoryHistory(),
+152 -15
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { noteOutline } from './outline'
import { useRouter } from 'vue-router'
import type { FileNode } from '@/contracts'
import * as workspaceService from '@/services/workspaceService'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import FileTreeNode from './FileTreeNode.vue'
import { DocumentAdd, FolderAdd } from '@element-plus/icons-vue'
import { Document, DocumentAdd, FolderAdd, ArrowRight } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
@@ -22,6 +23,64 @@ const selectedFolderPath = ref(
)
const contextTarget = ref<FileNode | null>(null)
const contextMenuPosition = ref({ x: 0, y: 0 })
const searchVisible = ref(false)
const activeTab = ref<'files' | 'outline'>('files')
function switchTab(tab: 'files' | 'outline') { activeTab.value = tab; closeContextMenu() }
function navigateTabs(event: KeyboardEvent) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
switchTab(event.key === 'Home' ? 'files' : event.key === 'End' ? 'outline' : activeTab.value === 'files' ? 'outline' : 'files')
const parent = (event.target as HTMLElement).parentElement
void nextTick(() => parent?.querySelector<HTMLButtonElement>('[aria-selected="true"]')?.focus())
}
const searchQuery = ref('')
const searchFocused = ref(false)
const createInput = ref<HTMLInputElement | null>(null)
const createError = ref('')
const creating = ref(false)
const outline = computed(() => noteOutline(editorStore.content))
const collapsedHeadings = ref(new Set<number>())
const visibleHeadings = computed(() => {
let hiddenBelow = 7
return outline.value.filter(heading => {
if (heading.level > hiddenBelow) return false
hiddenBelow = collapsedHeadings.value.has(heading.index) ? heading.level : 7
return true
})
})
const hasChildren = (index: number) => {
const position = outline.value.findIndex(heading => heading.index === index)
return (outline.value[position + 1]?.level ?? 0) > (outline.value[position]?.level ?? 6)
}
function toggleHeading(index: number) {
const next = new Set(collapsedHeadings.value)
if (next.has(index)) next.delete(index); else next.add(index)
collapsedHeadings.value = next
}
watch(() => editorStore.content, () => { collapsedHeadings.value = new Set() })
const filteredTree = computed(() => {
const query = searchQuery.value.trim().toLocaleLowerCase()
if (!query) return workspaceStore.fileTree
const filter = (nodes: FileNode[]): FileNode[] => nodes.flatMap(node => {
if (node.name.toLocaleLowerCase().includes(query)) return [{ ...node, is_open: true }]
const children = filter(node.children ?? [])
return children.length ? [{ ...node, children, is_open: true }] : []
})
return filter(workspaceStore.fileTree)
})
let lastScrollTop = 0
function revealSearch(event: WheelEvent) {
if (activeTab.value !== 'files') return
if (event.deltaY < 0) searchVisible.value = true
else if (event.deltaY > 0 && !searchFocused.value && !searchQuery.value) searchVisible.value = false
}
function onTreeScroll(event: Event) {
const top = (event.target as HTMLElement).scrollTop
if (top < lastScrollTop) searchVisible.value = true
else if (top > lastScrollTop && !searchFocused.value && !searchQuery.value) searchVisible.value = false
lastScrollTop = top
closeContextMenu()
}
watch(() => workspaceStore.activeFilePath, (path) => {
if (!path) return
@@ -30,14 +89,23 @@ watch(() => workspaceStore.activeFilePath, (path) => {
})
function beginCreate(type: 'file' | 'folder', parent = '/') {
if (creating.value) return
closeContextMenu()
createError.value = ''
newItemType.value = type
newItemName.value = ''
parentPath.value = parent
void nextTick(() => createInput.value?.focus())
}
async function createItem() {
const rawName = newItemName.value.trim()
if (!rawName || !newItemType.value) return
if (creating.value) return
if (/[\\/]/.test(rawName) || ['.', '..'].includes(rawName)) { createError.value = t('请输入有效名称,不要包含路径分隔符', 'Enter a name without path separators'); return }
creating.value = true
createError.value = ''
try {
if (newItemType.value === 'file') {
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
@@ -55,6 +123,8 @@ async function createItem() {
}
newItemType.value = null
newItemName.value = ''
} catch (error) { createError.value = error instanceof Error ? error.message : t('创建失败', 'Creation failed') }
finally { creating.value = false }
}
async function openNode(node: FileNode) {
@@ -84,7 +154,7 @@ function openContextMenu(event: MouseEvent, node: FileNode) {
selectedTreePath.value = node.path
selectedFolderPath.value = node.type === 'folder' ? node.path : containingFolder(node.path)
contextTarget.value = node
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
contextMenuPosition.value = { x: Math.max(8, Math.min(event.clientX, window.innerWidth - 170)), y: Math.max(8, Math.min(event.clientY, window.innerHeight - 170)) }
}
function closeContextMenu() { contextTarget.value = null }
@@ -136,38 +206,105 @@ function containingFolder(path: string): string {
</script>
<template>
<section class="file-tree-panel" @click="closeContextMenu">
<section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch">
<div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs">
<button id="workspace-files-tab" role="tab" aria-controls="workspace-files-panel" :aria-selected="activeTab === 'files'" :tabindex="activeTab === 'files' ? 0 : -1" @click="switchTab('files')">{{ t('文件', 'Files') }}</button>
<button id="workspace-outline-tab" role="tab" aria-controls="workspace-outline-panel" :aria-selected="activeTab === 'outline'" :tabindex="activeTab === 'outline' ? 0 : -1" @click="switchTab('outline')">{{ t('大纲', 'Outline') }}</button>
</div>
<div v-show="activeTab === 'files'" id="workspace-files-panel" class="files-panel" role="tabpanel" aria-labelledby="workspace-files-tab">
<div class="toolbar">
<button type="button" :title="t('新建笔记', 'New note')" :aria-label="t('新建笔记', 'New note')" @click.stop="beginCreate('file', selectedFolderPath)"><AppIcon :icon="DocumentAdd" /></button>
<button type="button" :title="t('新建文件夹', 'New folder')" :aria-label="t('新建文件夹', 'New folder')" @click.stop="beginCreate('folder', selectedFolderPath)"><AppIcon :icon="FolderAdd" /></button>
<button type="button" :aria-label="t('搜索文件', 'Search files')" :aria-expanded="searchVisible" @click="searchVisible = !searchVisible">{{ t('搜索', 'Search') }}</button>
</div>
<div v-if="searchVisible || searchQuery || searchFocused" class="file-search">
<input v-model="searchQuery" type="search" :placeholder="t('搜索文件或文件夹…', 'Search files or folders…')" :aria-label="t('搜索文件或文件夹', 'Search files or folders')" @focus="searchFocused = true" @blur="searchFocused = false" />
</div>
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
<input v-model="newItemName" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" autofocus />
<button type="submit">{{ t('创建', 'Create') }}</button>
<button type="button" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
<input ref="createInput" v-model="newItemName" :disabled="creating" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" />
<button type="submit" :disabled="creating">{{ t('创建', 'Create') }}</button>
<button type="button" :disabled="creating" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
</form>
<div class="tree">
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
<p v-if="createError" class="create-error" role="alert">{{ createError }}</p>
<div class="tree" @scroll.passive="onTreeScroll" @contextmenu.self="openContextMenu($event, { id: 'root', name: '/', path: '/', type: 'folder' })">
<FileTreeNode v-for="node in filteredTree" :key="node.id" :node="node"
:active-path="selectedTreePath" @open="openNode" @context-menu="openContextMenu" />
<p v-if="searchQuery && !filteredTree.length" class="subtle">{{ t('没有匹配的文件', 'No matching files') }}</p>
</div>
</div>
<div v-show="activeTab === 'outline'" id="workspace-outline-panel" class="outline-panel" role="tabpanel" aria-labelledby="workspace-outline-tab">
<div class="outline-document">
<span class="outline-document-icon"><AppIcon :icon="Document" :size="18" /></span>
<div class="outline-document-info">
<p class="outline-filename" :title="editorStore.currentFilePath ?? ''">{{ editorStore.currentFilePath?.split('/').pop() ?? t('未打开笔记', 'No note open') }}</p>
<span class="outline-meta">{{ t('文档目录', 'Contents') }} · {{ outline.length }} {{ t('个标题', 'headings') }}</span>
</div>
</div>
<div v-if="outline.length" class="outline-controls">
<span>{{ t('目录', 'Contents') }}</span>
<button :title="t('展开全部标题', 'Expand all headings')" @click="collapsedHeadings = new Set()">{{ t('全部展开', 'Expand all') }}</button>
</div>
<nav class="outline-list" :aria-label="t('当前笔记大纲', 'Current note outline')">
<div v-for="heading in visibleHeadings" :key="heading.index" class="outline-row" :class="{ 'is-selected': editorStore.headingRequest?.path === editorStore.currentFilePath && editorStore.headingRequest?.index === heading.index, 'is-nested': heading.level > 1 }" :style="{ marginLeft: `${(heading.level - 1) * 10}px` }">
<button v-if="hasChildren(heading.index)" class="outline-toggle" :aria-label="t('折叠或展开标题', 'Toggle heading')" :aria-expanded="!collapsedHeadings.has(heading.index)" @click="toggleHeading(heading.index)"><AppIcon :icon="ArrowRight" :size="10" /></button>
<span v-else class="outline-spacer" />
<button class="outline-title" :title="heading.title" :aria-current="editorStore.headingRequest?.path === editorStore.currentFilePath && editorStore.headingRequest?.index === heading.index ? 'location' : undefined" @click="editorStore.jumpToHeading(heading.index, heading.offset)"><span class="outline-text">{{ heading.title }}</span><span class="outline-level" aria-hidden="true">H{{ heading.level }}</span></button>
</div>
<div v-if="!outline.length" class="outline-empty"><AppIcon :icon="Document" :size="28" /><strong>{{ t('还没有目录', 'No outline yet') }}</strong><p>{{ t('在笔记中添加标题,即可在这里浏览和跳转。', 'Add headings to your note to navigate here.') }}</p></div>
</nav>
</div>
<Teleport to="body">
<div v-if="contextTarget" class="context-menu"
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
<button @click="renameTarget">{{ t('重命名', 'Rename') }}</button>
<button class="danger" @click="deleteTarget">{{ t('删除', 'Delete') }}</button>
<button @click="beginCreate('file', selectedFolderPath)">{{ t('新建文件', 'New file') }}</button>
<button @click="beginCreate('folder', selectedFolderPath)">{{ t('新建文件夹', 'New folder') }}</button>
<button v-if="contextTarget.path !== '/'" @click="renameTarget">{{ t('重命名', 'Rename') }}</button>
<button v-if="contextTarget.path !== '/'" class="danger" @click="deleteTarget">{{ t('删除', 'Delete') }}</button>
</div>
</Teleport>
</section>
</template>
<style scoped>
.file-tree-panel { height: 100%; }
.file-tree-panel { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--color-surface-secondary); color: var(--color-text-primary); }
.workspace-tabs { display: flex; flex-shrink: 0; gap: 4px; padding: 8px; border-bottom: 1px solid var(--color-border-default); background: var(--color-background-secondary); }
.workspace-tabs button { flex: 1; min-height: 34px; font-weight: 600; color: var(--color-text-secondary); }
.workspace-tabs button[aria-selected="true"] { background: var(--color-accent-soft); color: var(--color-accent-primary); box-shadow: inset 0 -2px var(--color-accent-primary); }
.files-panel { display: flex; flex: 1; min-height: 0; flex-direction: column; }
.file-tree-panel button:focus-visible, .context-menu button:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: -2px; }
.file-search { padding: 8px; }
.file-search input { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); background: var(--color-surface-primary); color: var(--color-text-primary); }
.create-error { padding: 8px; color: var(--color-error); }
.outline-panel { flex: 1; min-height: 0; overflow: auto; }
.outline-document { display: flex; align-items: center; gap: 10px; margin: 12px 10px; padding: 12px 10px; border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-surface-primary); box-shadow: var(--shadow-sm); }
.outline-document-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 36px; border-radius: var(--radius-sm); background: var(--color-accent-soft); color: var(--color-accent-primary); }
.outline-document-info { min-width: 0; }
.outline-filename { margin: 0 0 4px; padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-primary); font-size: var(--font-size-sm); font-weight: 600; border: 0; }
.outline-meta { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
.outline-controls { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px 8px; color: var(--color-text-secondary); font-size: var(--font-size-xs); }
.outline-controls button { color: var(--color-accent-primary); font-size: inherit; }
.outline-list { padding: 0 10px 16px; }
.outline-row { position: relative; display: flex; align-items: center; min-height: 34px; margin-bottom: 2px; padding: 0 6px 0 2px; border: 1px solid transparent; border-radius: var(--radius-sm); transition: background-color var(--motion-fast); }
.outline-row:hover { background: var(--color-background-hover); }
.outline-row.is-selected { background: var(--color-accent-soft); box-shadow: inset 2px 0 var(--color-accent-primary); }
.outline-spacer, .outline-toggle { width: 18px; flex-shrink: 0; }
.outline-row .outline-toggle { display: grid; place-items: center; padding: 4px 0; color: var(--color-text-secondary); }
.outline-toggle[aria-expanded="true"] :deep(svg) { transform: rotate(90deg); }
.outline-row .outline-title { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; padding: 6px 2px; text-align: left; background: transparent; }
.outline-level { flex-shrink: 0; color: var(--color-text-tertiary); font: 400 10px/18px var(--font-ui-mono); }
.outline-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-sm); line-height: 20px; }
.is-selected .outline-text { color: var(--color-accent-primary); font-weight: 600; }
.is-selected .outline-level { color: var(--color-accent-primary); }
.outline-empty { display: grid; justify-items: center; gap: 10px; padding: 32px 16px; text-align: center; color: var(--color-text-secondary); }
.outline-empty strong { color: var(--color-text-primary); font-size: var(--font-size-sm); }
.outline-empty p { margin: 0; font-size: var(--font-size-xs); line-height: 1.7; }
.toolbar { display: flex; gap: var(--space-xs); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
button { border: 0; border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); background: transparent; color: inherit; cursor: pointer; }
button:hover { background: var(--color-background-secondary); }
button:hover { background: var(--color-background-hover); }
.new-item { display: flex; gap: var(--space-xs); padding: var(--space-sm); }
.new-item input { min-width: 0; flex: 1; }
.tree { padding: var(--space-xs); }
.new-item input { min-width: 0; flex: 1; padding: 6px 8px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); background: var(--color-surface-primary); color: var(--color-text-primary); }
.file-search input:focus, .new-item input:focus { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
.tree { padding: var(--space-xs); flex: 1; min-height: 80px; overflow: auto; }
.context-menu { position: fixed; z-index: 1000; display: grid; min-width: 130px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-primary); box-shadow: var(--shadow-md); }
.context-menu button { text-align: left; }
.context-menu .danger { color: var(--color-error); }
@@ -0,0 +1,14 @@
import { expect, it } from 'vitest'
import { noteOutline } from './outline'
it('hides legacy metadata while preserving editor heading positions', () => {
const source = '***\n\ntitle: Python\ntags: python\n---\n\n# Variables\n'
expect(noteOutline(source)).toEqual([{ index: 0, level: 1, title: 'Variables', offset: source.indexOf('# Variables') }])
})
it('keeps duplicate headings distinct and skips code fences', () => {
const source = '# Same\n\n```md\n# Not a heading\n```\n\n## Same\n\nSetext\n---\n'
expect(noteOutline(source).map(h => [h.index, h.level, h.title, source.slice(h.offset, h.offset + 2)])).toEqual([
[0, 1, 'Same', '# '], [1, 2, 'Same', '##'], [2, 2, 'Setext', 'Se'],
])
})
@@ -0,0 +1,20 @@
import { marked } from 'marked'
import { splitNoteMetadata } from '../editor/noteMetadata'
export interface OutlineHeading { index: number; level: number; title: string; offset: number }
export function noteOutline(source: string): OutlineHeading[] {
const headings: OutlineHeading[] = []
const metadata = splitNoteMetadata(source)
let offset = metadata?.prefix.length ?? 0
let headingIndex = 0
for (const token of marked.lexer(metadata?.body ?? source)) {
const start = source.indexOf(token.raw, offset)
if (token.type === 'heading') {
const index = headingIndex++
headings.push({ index, level: token.depth, title: token.text.replace(/[*_`]/g, ''), offset: Math.max(0, start) })
}
if (start >= 0) offset = start + token.raw.length
}
return headings
}