feat(frontend): 完善工作区导航与笔记属性并适配手帐主题
This commit is contained in:
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user