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
+11 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
@@ -7,6 +8,15 @@ import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
const sourceEditor = ref<HTMLTextAreaElement | null>(null)
watch(() => editorStore.headingRequest, request => {
const input = sourceEditor.value
if (!request || !input || request.path !== editorStore.currentFilePath) return
input.focus()
input.setSelectionRange(request.offset, request.offset)
const lines = input.value.slice(0, request.offset).split('\n').length - 1
input.scrollTop = lines * (parseFloat(getComputedStyle(input).lineHeight) || 24)
})
function updateContent(event: Event) {
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
@@ -16,7 +26,7 @@ function updateContent(event: Event) {
<template>
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
:initial-content="editorStore.content" />
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
</template>
@@ -11,6 +11,8 @@ import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels'
import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
import { getMarkdown } from '@milkdown/kit/utils'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
@@ -35,6 +37,22 @@ import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
const props = defineProps<{ initialContent: string }>()
const metadata = ref(splitNoteMetadata(props.initialContent))
const tagDraft = ref('')
function setTags(tags: string[]) {
if (!metadata.value || !crepe) return
const prefix = updateMetadataTags(metadata.value, tags)
const body = crepe.editor.action(getMarkdown())
metadata.value = splitNoteMetadata(prefix + body)
editorStore.updateContent(prefix + body)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
}
function addTags() {
const tags = tagDraft.value.split(/[,]/).map(tag => tag.trim()).filter(tag => tag && !/[\r\n"\\]/.test(tag))
if (!tags.length || !metadata.value) return
setTags([...metadata.value.tags, ...tags])
tagDraft.value = ''
}
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
@@ -123,7 +141,7 @@ function applyFontSizeValue() {
onMounted(async () => {
crepe = new Crepe({
root: editorRoot.value,
defaultValue: props.initialContent,
defaultValue: metadata.value?.body ?? props.initialContent,
features: { [Crepe.Feature.TopBar]: false },
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
@@ -196,8 +214,9 @@ onMounted(async () => {
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
if (markdown === previousMarkdown || markdown === editorStore.content) return
editorStore.updateContent(markdown)
const fullMarkdown = (metadata.value?.prefix ?? '') + markdown
if (markdown === previousMarkdown || fullMarkdown === editorStore.content) return
editorStore.updateContent(fullMarkdown)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
})
})
@@ -209,6 +228,19 @@ onMounted(async () => {
})
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
watch(() => editorStore.headingRequest, request => {
if (!request || request.path !== editorStore.currentFilePath || !crepe) return
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
let index = 0
view.state.doc.forEach((node, offset) => {
if (node.type.name !== 'heading') return
if (index++ !== request.index) return
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, offset + 1)).scrollIntoView())
view.focus()
})
})
})
onBeforeUnmount(() => { disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
@@ -253,7 +285,18 @@ defineExpose({ getEditor: () => crepe?.editor })
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
</div>
<div v-if="loading" class="editor-loading">{{ t('正在加载编辑器', 'Loading editor') }}</div>
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
<div class="milkdown-host" :class="{ loading }">
<section v-if="metadata" class="note-metadata" :aria-label="t('笔记属性', 'Note properties')">
<span class="metadata-caption">{{ t('笔记属性', 'Note properties') }}</span>
<h1 v-if="metadata.title">{{ metadata.title }}</h1>
<div class="metadata-tags">
<span class="metadata-label">{{ t('标签', 'Tags') }}</span>
<span v-for="tag in metadata.tags" :key="tag" class="metadata-tag"><span>{{ tag }}</span><button type="button" :aria-label="`${t('移除标签', 'Remove tag')} ${tag}`" @click="setTags(metadata.tags.filter(item => item !== tag))">×</button></span>
<form @submit.prevent="addTags"><input v-model="tagDraft" :aria-label="t('添加标签', 'Add tag')" :placeholder="t('+ 添加标签', '+ Add tag')" /><button v-if="tagDraft.trim()" type="submit">{{ t('添加', 'Add') }}</button></form>
</div>
</section>
<div ref="editorRoot" />
</div>
</div>
</template>
@@ -282,6 +325,17 @@ defineExpose({ getEditor: () => crepe?.editor })
.toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); }
.milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
.milkdown-host.loading { visibility: hidden; }
.note-metadata { box-sizing: border-box; width: 90%; margin: 0 auto 20px; padding: 20px 24px; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); }
.metadata-caption { color: var(--color-text-secondary); font-size: var(--font-size-xs); }
.note-metadata h1 { margin: 10px 0 16px; font-size: 24px; color: var(--color-text-primary); overflow-wrap: anywhere; }
.metadata-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.metadata-label { margin-right: 4px; color: var(--color-text-secondary); font-size: var(--font-size-sm); }
.metadata-tag { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 8px; border-radius: var(--radius-full); background: var(--color-accent-soft); color: var(--color-accent-primary); font-size: var(--font-size-sm); }
.metadata-tag > span { overflow-wrap: anywhere; min-width: 0; }
.metadata-tag button { color: inherit; padding: 0 3px; }
.metadata-tags form { display: flex; gap: 6px; }
.metadata-tags input { width: 110px; padding: 5px 8px; border: 1px dashed var(--color-border-default); border-radius: var(--radius-sm); background: transparent; color: var(--color-text-primary); }
.metadata-tags input:focus { outline: 2px solid var(--color-border-focus); }
.milkdown-host :deep(.editor-mermaid-preview) { padding: 20px; overflow: auto; background: var(--color-surface-primary); color: var(--color-text-primary); }
.milkdown-host :deep(.editor-mermaid-preview svg) { display: block; max-width: 100%; height: auto; margin: auto; }
.milkdown-host :deep(.editor-mermaid-preview.has-error) { color: var(--color-error); white-space: pre-wrap; }
@@ -0,0 +1,18 @@
import { expect, it } from 'vitest'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
it('renders legacy properties and saves real frontmatter without losing other fields', () => {
const note = '***\n\ntitle: Python\ntags: python, 编程\nembedding_local_only: true\n----------------\n\n# 正文\n'
const metadata = splitNoteMetadata(note)!
expect(metadata.tags).toEqual(['python', '编程'])
expect(metadata.body).toBe('\n# 正文\n')
const prefix = updateMetadataTags(metadata, ['编程', '学习', '学习'])
expect(prefix).toContain('embedding_local_only: true')
expect(prefix).toContain('tags: ["编程","学习"]')
expect(prefix.startsWith('---\n')).toBe(true)
expect(splitNoteMetadata(prefix + metadata.body)!.tags).toEqual(['编程', '学习'])
})
it('does not mistake ordinary Markdown for metadata', () => {
expect(splitNoteMetadata('---\nA paragraph\n---\n')).toBeNull()
})
@@ -0,0 +1,23 @@
// TODO(desktop): 第三阶段顶部「段落 → 导入为笔记属性」复用属性解析边界,
// 补齐无损 YAML、冲突合并与可撤销事务;见 docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md。
export interface NoteMetadata { prefix: string; yaml: string; body: string; title: string; tags: string[] }
export function splitNoteMetadata(source: string): NoteMetadata | null {
const match = source.match(/^\uFEFF?(---|\*\*\*)[ \t]*\r?\n([\s\S]*?)\r?\n(?:-{3,}|\.\.\.)[ \t]*(?:\r?\n|$)/)
if (!match) return null
const yaml = match[2]!
// Only recognize metadata with explicit fields, not ordinary thematic breaks.
const title = yaml.match(/^title:[ \t]*(.*)$/m)?.[1]?.trim() ?? ''
const rawTags = yaml.match(/^tags:[ \t]*(.*)$/m)?.[1]?.trim()
if (!title && rawTags === undefined) return null
// Complex YAML values remain editable in source mode, never partially rewritten.
if (/^(?:[|>]|\{)/.test(title) || (rawTags === '' && /^\s+-\s/m.test(yaml))) return null
const tags = rawTags?.replace(/^\[|\]$/g, '').split(',').map(tag => tag.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean) ?? []
return { prefix: match[0], yaml, body: source.slice(match[0].length), title: title.replace(/^['"]|['"]$/g, ''), tags }
}
export function updateMetadataTags(metadata: NoteMetadata, tags: string[]): string {
const line = `tags: ${JSON.stringify([...new Set(tags)])}`
const yaml = /^tags:/m.test(metadata.yaml) ? metadata.yaml.replace(/^tags:.*$/m, () => line) : `${metadata.yaml}\n${line}`
return `---\n${yaml.trim()}\n---\n`
}
@@ -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
}