feat(frontend): 完善工作区导航与笔记属性并适配手帐主题
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
- [后端接口契约](contracts/后端接口契约-开发版.md)
|
- [后端接口契约](contracts/后端接口契约-开发版.md)
|
||||||
- [第二阶段接口契约](contracts/第二阶段接口契约-开发版.md)
|
- [第二阶段接口契约](contracts/第二阶段接口契约-开发版.md)
|
||||||
- [前端页面需求说明](contracts/前端页面需求说明-开发版.md)
|
- [前端页面需求说明](contracts/前端页面需求说明-开发版.md)
|
||||||
|
- [Tauri / Rust 桌面客户端需求说明(第三阶段,计划)](contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md)
|
||||||
|
|
||||||
运行中的后端以 `/openapi.json` 为机器可读事实来源。接口契约用于描述设计意图、联调约束和实现状态;两者不一致时,应先确认代码行为,再在同一个 PR 中同步修正文档或实现。
|
运行中的后端以 `/openapi.json` 为机器可读事实来源。接口契约用于描述设计意图、联调约束和实现状态;两者不一致时,应先确认代码行为,再在同一个 PR 中同步修正文档或实现。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Tauri / Rust 桌面客户端需求说明(第三阶段)
|
||||||
|
|
||||||
|
状态:需求预留,尚未实现桌面客户端。本文不表示已有可调用的 Tauri Command 或可发布安装包。
|
||||||
|
|
||||||
|
基线日期:2026-09-05。
|
||||||
|
|
||||||
|
## 1. 目标与边界
|
||||||
|
|
||||||
|
第三阶段在现有 Vue 编辑器和 FastAPI AI Core 上接入 Tauri 2 / Rust Host,提供原生窗口、菜单、多 Vault 文件管理、安全凭据存储和 Sidecar 生命周期管理。
|
||||||
|
|
||||||
|
- Vue 负责页面、编辑事务、主题和交互状态;通过既有 Service 边界调用能力,不在组件中散布平台判断。
|
||||||
|
- Rust Host 负责系统能力、路径权限、原生菜单事件及受控进程生命周期。
|
||||||
|
- FastAPI AI Core 保留笔记解析、索引、检索、模型和 Agent 业务职责;同一文件不得同时由 Host 和 AI Core 无协调地写入。
|
||||||
|
- Web 模式保留可运行能力;桌面专有功能通过能力检测显隐,不用无响应按钮假装已实现。
|
||||||
|
|
||||||
|
架构依据:[技术栈说明](../architecture/AI笔记软件技术栈说明-团队版-v2.3.md)、[前端页面需求](前端页面需求说明-开发版.md)、[第二阶段接口契约](第二阶段接口契约-开发版.md)。
|
||||||
|
|
||||||
|
## 2. 顶部菜单与元数据格式一键导入
|
||||||
|
|
||||||
|
### 2.1 入口预留
|
||||||
|
|
||||||
|
桌面客户端顶部菜单栏的 **段落 → 导入为笔记属性…** 预留元数据格式导入功能,与标题、正文、列表等段落操作归组。它处理笔记内容中的元数据,不是主题包安装入口。
|
||||||
|
|
||||||
|
建议稳定的前端命令标识为 `editor.import-note-properties`,仅为设计标识,尚未注册为 Tauri IPC。原生菜单和编辑器命令面板应分发同一命令,避免两套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键。
|
||||||
|
|
||||||
|
### 2.2 输入与转换规则
|
||||||
|
|
||||||
|
1. 无选区时识别当前笔记开头的属性块;有选区时只处理完整的属性块。无活动笔记、加载中、只读或冲突状态下禁用操作,并提供原因。
|
||||||
|
2. 支持标准 YAML frontmatter,以及历史编辑器产生的 `***` 开头、`title:` / `tags:` 字段、横线结尾的兼容形式。普通分隔线、代码块和包含冒号的正文不得被误判。
|
||||||
|
3. 将识别成功的内容规范化到文件头唯一的 `---` frontmatter 中,正文中的旧属性块仅在转换成功后移除。
|
||||||
|
4. 写作模式显示独立标题和可编辑标签;源码模式显示真实 `title` / `tags` 字段。标签必须进入现有保存和索引链路,能被标签筛选使用,不能只创建装饰性标签元素。
|
||||||
|
5. 保留未知属性及其类型,特别是 `embedding_local_only` 等行为配置。复杂 YAML 不得用正则拆分后静默丢弃;无法无损处理时说明原因,并保留原文供源码编辑。
|
||||||
|
6. 标签支持字符串、逗号分隔值和 YAML 列表,去重并保留顺序;中文、空格、转义字符须正确往返。空标签与删除标签有明确语义。
|
||||||
|
7. 已存在 frontmatter 时合并到同一个属性块;字段值冲突时展示差异供用户选择,禁止静默覆盖。重复执行不重复添加标签或属性块。
|
||||||
|
|
||||||
|
### 2.3 编辑与保存行为
|
||||||
|
|
||||||
|
- 无歧义转换一次菜单操作完成,并构成一个可撤销的编辑事务;转换失败不得改变文档或保存状态。
|
||||||
|
- 转换作用于当前内存文档,不先从磁盘读取旧内容覆盖未保存编辑。操作绑定文件标识和文档版本,异步处理期间切换文件或继续编辑时,应取消或重新校验。
|
||||||
|
- 成功后进入现有脏状态和自动保存流程。磁盘保存失败显示可重试状态,撤销/重做同时恢复正文、属性及标签。
|
||||||
|
- 属性块不进入正文大纲;标题跳转、引用定位仍使用完整原文件的正确偏移。写作/源码切换、保存后重开不得改变属性语义。
|
||||||
|
- 当前分支的 `frontend/src/features/editor/noteMetadata.ts` 仅是简单属性块展示与标签编辑基础;桌面阶段需补齐完整解析、合并冲突、单事务撤销和原生菜单分发,不能直接视为本节已经验收。
|
||||||
|
|
||||||
|
## 3. 桌面基础需求
|
||||||
|
|
||||||
|
| 模块 | 第三阶段要求 | 验收要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 窗口与菜单 | 原生窗口控制、顶部菜单、焦点分发、关闭前未保存处理 | 菜单操作针对活动编辑器;多窗口不串文档;取消关闭保留编辑 |
|
||||||
|
| Vault 与文件系统 | 原生目录选择、多 Vault、最近打开、文件监听、路径规范化 | 未授权目录不可访问;重命名同步树和打开文件;外部修改不静默覆盖 |
|
||||||
|
| 写入与恢复 | 原子写入、版本/内容摘要校验、失败重试和异常退出恢复 | 不产生半写文件;并发保存不覆盖新版本;恢复流程可验证 |
|
||||||
|
| AI Core Sidecar | 启停、健康检查、日志、崩溃恢复、退出清理 | 不残留进程;不可用时显示原因;本地通信有访问控制 |
|
||||||
|
| 凭据 | 按既有架构接入 Stronghold/平台安全存储,制定开发凭据迁移方案 | 前端只持有凭据引用;不回显密钥;失败可恢复且不丢凭据 |
|
||||||
|
| MCP 与插件 | 按已冻结的 Host 沙箱契约落实文件、网络和子进程授权 | 沿用审批边界,不因桌面集成默认放开权限 |
|
||||||
|
| 主题 | 复用主题包校验;原生文件选择和下载适配共用检查流程 | 导入不自动启用;安装失败可恢复;ZIP 路径和资源限制继续有效 |
|
||||||
|
| 外观与导航 | 继承主题、代码配色、相对纸页宽度、文件/大纲切换 | 窗口缩放、高 DPI、深浅主题下无截断;键盘导航完整 |
|
||||||
|
| 发布 | Windows、macOS、Linux 构建与安装验证;签名、升级及回滚方案 | 未准备好签名和回滚前不启用自动更新;平台差异有说明 |
|
||||||
|
|
||||||
|
云同步服务、移动端和主题社区服务端不因本文自动纳入第三阶段必交范围;需要单独确认范围与接口。
|
||||||
|
|
||||||
|
## 4. 开发顺序与验收
|
||||||
|
|
||||||
|
1. 冻结 Host 能力与 Service 适配接口,明确每类数据的写入责任方及权限模型。
|
||||||
|
2. 接入窗口、菜单与编辑命令路由,完成“段落 → 导入为笔记属性…”的编辑器事务。
|
||||||
|
3. 接入 Vault、文件监听、冲突处理、Sidecar 和凭据迁移。
|
||||||
|
4. 完成平台测试、安装包和升级恢复验收。
|
||||||
|
|
||||||
|
元数据导入专项测试至少覆盖:标准/历史格式、普通正文误判、代码围栏、未知字段、复杂 YAML、同名字段冲突、重复导入、中文标签、撤销重做、未保存文档、处理中切换文件、保存失败、重开后标签检索,以及写作/源码模式的大纲与引用偏移。
|
||||||
|
|
||||||
|
第三阶段实现 PR 必须补充实际 Command 名称、输入输出类型、错误码、平台差异和测试证据;在此之前本文所有 Host 能力均标为计划实现。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
theme_id: paper-moments
|
theme_id: paper-moments
|
||||||
name: 纸间时光 · Paper Moments
|
name: 纸间时光 · Paper Moments
|
||||||
version: 1.2.0
|
version: 1.4.0
|
||||||
author: NotesAgent
|
author: NotesAgent
|
||||||
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
||||||
min_app_version: 0.2.0
|
min_app_version: 0.2.0
|
||||||
@@ -131,6 +131,10 @@ license: MIT
|
|||||||
border-right: 1px dashed #b5a693;
|
border-right: 1px dashed #b5a693;
|
||||||
}
|
}
|
||||||
[data-theme="paper-moments"] .primary-sidebar { background: #f1e9dc; }
|
[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"] .file-tree-panel .toolbar,
|
||||||
[data-theme="paper-moments"] .sidebar-header { background: #e5eeee; border-bottom: 1px dashed #b5a693; }
|
[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; }
|
[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 .tools { margin-left: 72px; }
|
||||||
[data-theme="paper-moments"] .milkdown-code-block .cm-activeLine,
|
[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"] .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>
|
<template>
|
||||||
<aside class="secondary-sidebar">
|
<aside class="secondary-sidebar">
|
||||||
<div class="sidebar-header">
|
<div v-if="component !== 'file-tree'" class="sidebar-header">
|
||||||
<h3 class="sidebar-title">{{ sidebarTitle }}</h3>
|
<h3 class="sidebar-title">{{ sidebarTitle }}</h3>
|
||||||
<div v-if="showSkillToggle" class="sidebar-tabs">
|
<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/skills" class="tab" :class="{ active: routeName === 'skills' }">Skill</router-link>
|
||||||
<router-link to="/extensions/plugins" class="tab" :class="{ active: routeName === 'plugins' }">Plugin</router-link>
|
<router-link to="/extensions/plugins" class="tab" :class="{ active: routeName === 'plugins' }">Plugin</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content" :class="{ 'file-sidebar-content': component === 'file-tree' }">
|
||||||
<FileTreePanel v-if="component === 'file-tree'" />
|
<FileTreePanel v-if="component === 'file-tree'" />
|
||||||
<ConversationListPanel v-else-if="component === 'conversation-list'" />
|
<ConversationListPanel v-else-if="component === 'conversation-list'" />
|
||||||
<RunListPanel v-else-if="component === 'run-list'" />
|
<RunListPanel v-else-if="component === 'run-list'" />
|
||||||
@@ -111,5 +111,6 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
.file-sidebar-content { min-height: 0; overflow: hidden; scrollbar-gutter: auto; }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
@@ -7,6 +8,15 @@ import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
|||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const themeStore = useThemeStore()
|
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) {
|
function updateContent(event: Event) {
|
||||||
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
|
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
|
||||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||||
@@ -16,7 +26,7 @@ function updateContent(event: Event) {
|
|||||||
<template>
|
<template>
|
||||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
||||||
:initial-content="editorStore.content" />
|
: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" />
|
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import './language-icons.css'
|
|||||||
import { installLanguagePickerPopover } from './languagePickerPopover'
|
import { installLanguagePickerPopover } from './languagePickerPopover'
|
||||||
import { installCodeBlockLabels } from './codeBlockLabels'
|
import { installCodeBlockLabels } from './codeBlockLabels'
|
||||||
import { createMermaidPreview } from './mermaidPreview'
|
import { createMermaidPreview } from './mermaidPreview'
|
||||||
|
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
|
||||||
|
import { getMarkdown } from '@milkdown/kit/utils'
|
||||||
import {
|
import {
|
||||||
createCodeBlockCommand,
|
createCodeBlockCommand,
|
||||||
toggleEmphasisCommand,
|
toggleEmphasisCommand,
|
||||||
@@ -35,6 +37,22 @@ import '@milkdown/crepe/theme/common/style.css'
|
|||||||
import '@milkdown/crepe/theme/frame.css'
|
import '@milkdown/crepe/theme/frame.css'
|
||||||
|
|
||||||
const props = defineProps<{ initialContent: string }>()
|
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 editorStore = useEditorStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
@@ -123,7 +141,7 @@ function applyFontSizeValue() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
crepe = new Crepe({
|
crepe = new Crepe({
|
||||||
root: editorRoot.value,
|
root: editorRoot.value,
|
||||||
defaultValue: props.initialContent,
|
defaultValue: metadata.value?.body ?? props.initialContent,
|
||||||
features: { [Crepe.Feature.TopBar]: false },
|
features: { [Crepe.Feature.TopBar]: false },
|
||||||
featureConfigs: {
|
featureConfigs: {
|
||||||
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
|
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
|
||||||
@@ -196,8 +214,9 @@ onMounted(async () => {
|
|||||||
crepe.on((listener) => {
|
crepe.on((listener) => {
|
||||||
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
||||||
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
|
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
|
||||||
if (markdown === previousMarkdown || markdown === editorStore.content) return
|
const fullMarkdown = (metadata.value?.prefix ?? '') + markdown
|
||||||
editorStore.updateContent(markdown)
|
if (markdown === previousMarkdown || fullMarkdown === editorStore.content) return
|
||||||
|
editorStore.updateContent(fullMarkdown)
|
||||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -209,6 +228,19 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
|
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() })
|
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>
|
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="editor-loading">{{ t('正在加载编辑器…', 'Loading editor…') }}</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>
|
</div>
|
||||||
</template>
|
</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); }
|
.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 { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
|
||||||
.milkdown-host.loading { visibility: hidden; }
|
.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) { 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 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; }
|
.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', () => {
|
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 () => {
|
it('switches both workspace selection and editor content on consecutive clicks', async () => {
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRouter } from 'vue-router'
|
||||||
import type { FileNode } from '@/contracts'
|
import type { FileNode } from '@/contracts'
|
||||||
import * as workspaceService from '@/services/workspaceService'
|
import * as workspaceService from '@/services/workspaceService'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { useWorkspaceStore } from '@/stores/workspace'
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
import FileTreeNode from './FileTreeNode.vue'
|
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 AppIcon from '@/components/common/AppIcon.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
@@ -22,6 +23,64 @@ const selectedFolderPath = ref(
|
|||||||
)
|
)
|
||||||
const contextTarget = ref<FileNode | null>(null)
|
const contextTarget = ref<FileNode | null>(null)
|
||||||
const contextMenuPosition = ref({ x: 0, y: 0 })
|
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) => {
|
watch(() => workspaceStore.activeFilePath, (path) => {
|
||||||
if (!path) return
|
if (!path) return
|
||||||
@@ -30,14 +89,23 @@ watch(() => workspaceStore.activeFilePath, (path) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function beginCreate(type: 'file' | 'folder', parent = '/') {
|
function beginCreate(type: 'file' | 'folder', parent = '/') {
|
||||||
|
if (creating.value) return
|
||||||
|
closeContextMenu()
|
||||||
|
createError.value = ''
|
||||||
newItemType.value = type
|
newItemType.value = type
|
||||||
newItemName.value = ''
|
newItemName.value = ''
|
||||||
parentPath.value = parent
|
parentPath.value = parent
|
||||||
|
void nextTick(() => createInput.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createItem() {
|
async function createItem() {
|
||||||
const rawName = newItemName.value.trim()
|
const rawName = newItemName.value.trim()
|
||||||
if (!rawName || !newItemType.value) return
|
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') {
|
if (newItemType.value === 'file') {
|
||||||
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
|
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
|
||||||
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
|
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
|
||||||
@@ -55,6 +123,8 @@ async function createItem() {
|
|||||||
}
|
}
|
||||||
newItemType.value = null
|
newItemType.value = null
|
||||||
newItemName.value = ''
|
newItemName.value = ''
|
||||||
|
} catch (error) { createError.value = error instanceof Error ? error.message : t('创建失败', 'Creation failed') }
|
||||||
|
finally { creating.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openNode(node: FileNode) {
|
async function openNode(node: FileNode) {
|
||||||
@@ -84,7 +154,7 @@ function openContextMenu(event: MouseEvent, node: FileNode) {
|
|||||||
selectedTreePath.value = node.path
|
selectedTreePath.value = node.path
|
||||||
selectedFolderPath.value = node.type === 'folder' ? node.path : containingFolder(node.path)
|
selectedFolderPath.value = node.type === 'folder' ? node.path : containingFolder(node.path)
|
||||||
contextTarget.value = node
|
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 }
|
function closeContextMenu() { contextTarget.value = null }
|
||||||
@@ -136,38 +206,105 @@ function containingFolder(path: string): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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 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" :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>
|
</div>
|
||||||
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
|
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
|
||||||
<input v-model="newItemName" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" autofocus />
|
<input ref="createInput" v-model="newItemName" :disabled="creating" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" />
|
||||||
<button type="submit">{{ t('创建', 'Create') }}</button>
|
<button type="submit" :disabled="creating">{{ t('创建', 'Create') }}</button>
|
||||||
<button type="button" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
|
<button type="button" :disabled="creating" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="tree">
|
<p v-if="createError" class="create-error" role="alert">{{ createError }}</p>
|
||||||
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
|
<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" />
|
: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>
|
</div>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="contextTarget" class="context-menu"
|
<div v-if="contextTarget" class="context-menu"
|
||||||
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
|
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
|
||||||
<button @click="renameTarget">{{ t('重命名', 'Rename') }}</button>
|
<button @click="beginCreate('file', selectedFolderPath)">{{ t('新建文件', 'New file') }}</button>
|
||||||
<button class="danger" @click="deleteTarget">{{ t('删除', 'Delete') }}</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>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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); }
|
.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 { 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 { display: flex; gap: var(--space-xs); padding: var(--space-sm); }
|
||||||
.new-item input { min-width: 0; flex: 1; }
|
.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); }
|
||||||
.tree { padding: var(--space-xs); }
|
.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 { 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 button { text-align: left; }
|
||||||
.context-menu .danger { color: var(--color-error); }
|
.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 currentFilePath = ref<string | null>(null)
|
||||||
const highlightBlockId = ref<string | null>(null)
|
const highlightBlockId = ref<string | null>(null)
|
||||||
const cursorPosition = ref({ line: 0, column: 0 })
|
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 wordCount = computed(() => {
|
||||||
const text = content.value.replace(/[#*`>\-_\[\]()!]/g, '')
|
const text = content.value.replace(/[#*`>\-_\[\]()!]/g, '')
|
||||||
@@ -144,6 +148,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
headingRequest,
|
||||||
|
jumpToHeading,
|
||||||
mode,
|
mode,
|
||||||
content,
|
content,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
|
|||||||
Reference in New Issue
Block a user