feat(frontend): 完善工作区导航与笔记属性并适配手帐主题
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
theme_id: paper-moments
|
||||
name: 纸间时光 · Paper Moments
|
||||
version: 1.2.0
|
||||
version: 1.4.0
|
||||
author: NotesAgent
|
||||
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
||||
min_app_version: 0.2.0
|
||||
@@ -131,6 +131,10 @@ license: MIT
|
||||
border-right: 1px dashed #b5a693;
|
||||
}
|
||||
[data-theme="paper-moments"] .primary-sidebar { background: #f1e9dc; }
|
||||
[data-theme="paper-moments"] .file-tree-panel { background: #fff9e9; }
|
||||
[data-theme="paper-moments"] .workspace-tabs { background: #e5eeee; border-bottom: 1px dashed #b5a693; }
|
||||
[data-theme="paper-moments"] .workspace-tabs button[aria-selected="true"] { background: #f8e9e3; color: #875343; box-shadow: inset 0 -2px #a77a67; }
|
||||
[data-theme="paper-moments"] .outline-filename { border-bottom: 1px dashed #b5a693; }
|
||||
[data-theme="paper-moments"] .file-tree-panel .toolbar,
|
||||
[data-theme="paper-moments"] .sidebar-header { background: #e5eeee; border-bottom: 1px dashed #b5a693; }
|
||||
[data-theme="paper-moments"] .editor-header { background: #f8e9e3; border-bottom: 1px solid #b5a693; }
|
||||
@@ -219,3 +223,46 @@ license: MIT
|
||||
[data-theme="paper-moments"] .milkdown-code-block .tools { margin-left: 72px; }
|
||||
[data-theme="paper-moments"] .milkdown-code-block .cm-activeLine,
|
||||
[data-theme="paper-moments"] .milkdown-code-block .cm-activeLineGutter { background: color-mix(in srgb, var(--color-code-text) 7%, transparent); }
|
||||
|
||||
[data-theme="paper-moments"] .note-metadata {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
margin: 8px auto 30px;
|
||||
padding: 24px 30px;
|
||||
border: 1px solid #887460;
|
||||
border-radius: 8px 14px 8px 8px;
|
||||
outline: 1px dashed #c5b9a7;
|
||||
outline-offset: -8px;
|
||||
background: linear-gradient(110deg, #fffdf5, #fbf5e4);
|
||||
box-shadow: 4px 5px 0 #d8e6e2, 8px 9px 0 #f0d8cf;
|
||||
}
|
||||
[data-theme="paper-moments"] .note-metadata::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 36px;
|
||||
width: 78px;
|
||||
height: 22px;
|
||||
background: repeating-linear-gradient(45deg, #c5dfe0c0 0 8px, #daecebb0 8px 16px);
|
||||
transform: rotate(3deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-theme="paper-moments"] .metadata-caption { color: #806b58; letter-spacing: .12em; }
|
||||
[data-theme="paper-moments"] .note-metadata h1 {
|
||||
margin: 12px 0 18px;
|
||||
color: #875343;
|
||||
font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif;
|
||||
font-size: clamp(20px, 2vw, 28px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
[data-theme="paper-moments"] .metadata-tags { padding-top: 14px; border-top: 1px dashed #c5b9a7; gap: 8px; }
|
||||
[data-theme="paper-moments"] .metadata-tag { border: 1px solid #d6b5a8; border-radius: 5px; background: #f5e3da; color: #704b3d; }
|
||||
[data-theme="paper-moments"] .metadata-tag:nth-of-type(2n + 1) { border-color: #b5cdcf; background: #e5eeee; color: #456671; }
|
||||
[data-theme="paper-moments"] .metadata-tag button { border-radius: 3px; cursor: pointer; }
|
||||
[data-theme="paper-moments"] .metadata-tag button:hover { background: #ffffff80; }
|
||||
[data-theme="paper-moments"] .metadata-tags input { border-color: #b5a693; background: #fffdf580; }
|
||||
[data-theme="paper-moments"] .metadata-tags form button { padding: 4px 10px; border: 1px solid #b5a693; border-radius: 5px; background: #f7edce; color: #704b3d; cursor: pointer; }
|
||||
[data-theme="paper-moments"] .metadata-tags button:focus-visible { outline: 2px solid #875343; outline-offset: 2px; }
|
||||
@media (max-width: 720px) {
|
||||
[data-theme="paper-moments"] .note-metadata { width: 100%; padding: 22px 18px; }
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
|
||||
|
||||
<template>
|
||||
<aside class="secondary-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div v-if="component !== 'file-tree'" class="sidebar-header">
|
||||
<h3 class="sidebar-title">{{ sidebarTitle }}</h3>
|
||||
<div v-if="showSkillToggle" class="sidebar-tabs">
|
||||
<router-link to="/extensions/skills" class="tab" :class="{ active: routeName === 'skills' }">Skill</router-link>
|
||||
<router-link to="/extensions/plugins" class="tab" :class="{ active: routeName === 'plugins' }">Plugin</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-content" :class="{ 'file-sidebar-content': component === 'file-tree' }">
|
||||
<FileTreePanel v-if="component === 'file-tree'" />
|
||||
<ConversationListPanel v-else-if="component === 'conversation-list'" />
|
||||
<RunListPanel v-else-if="component === 'run-list'" />
|
||||
@@ -111,5 +111,6 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.file-sidebar-content { min-height: 0; overflow: hidden; scrollbar-gutter: auto; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
const currentFilePath = ref<string | null>(null)
|
||||
const highlightBlockId = ref<string | null>(null)
|
||||
const cursorPosition = ref({ line: 0, column: 0 })
|
||||
const headingRequest = ref<{ index: number; offset: number; path: string | null } | null>(null)
|
||||
function jumpToHeading(index: number, offset: number) {
|
||||
headingRequest.value = { index, offset, path: currentFilePath.value }
|
||||
}
|
||||
|
||||
const wordCount = computed(() => {
|
||||
const text = content.value.replace(/[#*`>\-_\[\]()!]/g, '')
|
||||
@@ -144,6 +148,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
headingRequest,
|
||||
jumpToHeading,
|
||||
mode,
|
||||
content,
|
||||
saveStatus,
|
||||
|
||||
Reference in New Issue
Block a user