feat(knowledge): add graph tags daily notes and Markdown templates

This commit is contained in:
eezd committed 2026-10-22 14:56:48 +08:00
1 parent a923c2dfb9
commit 2a35a392b7
11 files changed
+209 -10

No files matched your search

+2 -2
View File
@@ -5,9 +5,9 @@ import { createNote, ws } from './workspace'
<template> <template>
<header class="rm-global-header"> <header class="rm-global-header">
<button class="rm-wordmark" @click="ws.settings = null; ws.images = null"><Icon name="edit" :size="22" /><span>RawMark</span></button> <button class="rm-wordmark" @click="ws.settings = null; ws.images = null; ws.view = 'notes'"><Icon name="edit" :size="22" /><span>RawMark</span></button>
<span class="rm-edition">个人知识库</span> <span class="rm-edition">个人知识库</span>
<nav class="rm-primary-nav" aria-label="工作区"><button class="on"><Icon name="folder" />笔记</button></nav> <nav class="rm-primary-nav" aria-label="工作区"><button :class="{ on: ws.view === 'notes' && ws.images === null }" @click="ws.view = 'notes'; ws.images = null"><Icon name="folder" />笔记</button><button :class="{ on: ws.view === 'graph' }" @click="ws.view = 'graph'; ws.images = null"><Icon name="graph" />图谱</button><button :class="{ on: ws.images !== null }" @click="ws.images = ''"><Icon name="image" />图片库</button></nav>
<button class="rm-global-search" @click="ws.switcher = ''"><Icon name="search" /><span>全局检索知识库</span><kbd>⌘ / Ctrl P</kbd></button> <button class="rm-global-search" @click="ws.switcher = ''"><Icon name="search" /><span>全局检索知识库</span><kbd>⌘ / Ctrl P</kbd></button>
<div class="rm-header-actions"> <div class="rm-header-actions">
<button class="rm-icon-btn" title="设置" @click="ws.settings = 'index'"><Icon name="settings" /></button> <button class="rm-icon-btn" title="设置" @click="ws.settings = 'index'"><Icon name="settings" /></button>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { metadata, refreshMetadata } from './metadata'
import { openNote, openReference, ws } from './workspace'
const query = ref('')
const tag = ref('')
const local = ref(false)
const camera = ref({ x: -420, y: -290, width: 840 })
const tags = computed(() => {
const counts = new Map<string, number>()
for (const note of metadata.notes) for (const name of note.tags) counts.set(name, (counts.get(name) ?? 0) + 1)
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'zh-CN'))
})
const visibleNotes = computed(() => {
const nearby = new Set([ws.active])
for (const note of metadata.notes) for (const link of note.links) if (note.path === ws.active || link.target === ws.active) { nearby.add(note.path); nearby.add(link.target) }
return metadata.notes.filter(note => (!local.value || nearby.has(note.path)) && (!tag.value || note.tags.includes(tag.value)) && `${note.path} ${note.title}`.toLocaleLowerCase().includes(query.value.toLocaleLowerCase()))
})
const graph = computed(() => {
const notes = [...visibleNotes.value].sort((a,b) => a.path.localeCompare(b.path))
const paths = new Set(notes.map(note => note.path))
const seen = new Set<string>()
const edges = notes.flatMap(note => note.links.filter(link => {
const key = note.path + '\0' + link.target
if (seen.has(key) || !paths.has(link.target)) return false
seen.add(key); return true
}).map(link => ({ from: note.path, to: link.target })))
const nodes = notes.map((note, i) => {
const center = local.value && note.path === ws.active
const angle = i * Math.PI * 2 / Math.min(notes.length, 28)
const radius = notes.length === 1 || center ? 0 : 145 + Math.floor(i / 28) * 90
return { ...note, x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, degree: edges.filter(edge => edge.from === note.path || edge.to === note.path).length }
})
const byPath = new Map(nodes.map(node => [node.path, node]))
return { nodes, edges: edges.map(edge => ({ from: byPath.get(edge.from)!, to: byPath.get(edge.to)! })) }
})
const missing = computed(() => {
const paths = new Set(metadata.notes.map(note => note.path))
return visibleNotes.value.flatMap(note => note.links.filter(link => !paths.has(link.target)).map(link => ({ ...link, path: note.path })))
})
function zoom(factor: number) { const old = camera.value; const width = Math.max(180, Math.min(5000, old.width * factor)); camera.value = { x: old.x + (old.width - width) / 2, y: old.y + (old.width - width) * .345, width } }
let drag: { x: number; y: number; left: number; top: number } | undefined
function start(event: PointerEvent) {
if (event.target instanceof Element && event.target.closest('[data-note]')) return
const svg = event.currentTarget as SVGSVGElement
svg.setPointerCapture(event.pointerId)
drag = { x: event.clientX, y: event.clientY, left: camera.value.x, top: camera.value.y }
}
function pan(event: PointerEvent) { if (!drag) return; const scale = camera.value.width / (event.currentTarget as SVGSVGElement).getBoundingClientRect().width; camera.value.x = drag.left - (event.clientX - drag.x) * scale; camera.value.y = drag.top - (event.clientY - drag.y) * scale }
watch(() => ws.view, () => { query.value = ''; tag.value = '' })
</script>
<template>
<section class="rm-knowledge-panel">
<header class="rm-knowledge-heading"><div><span class="rm-eyebrow">KNOWLEDGE CONNECTIONS</span><h1>{{ ws.view === 'graph' ? '让知识自然连接' : '标签索引' }}</h1><p>{{ metadata.notes.length }} 篇笔记,以标准 Markdown 链接与标签连接你的想法。</p></div><button class="rm-btn" @click="ws.view = 'notes'">返回笔记</button></header>
<div class="rm-knowledge-tools"><label><Icon name="search" /><input v-model="query" type="search" placeholder="搜索笔记…" aria-label="搜索笔记" /></label><select v-model="tag" aria-label="筛选标签"><option value="">全部标签</option><option v-for="[name, count] in tags" :key="name" :value="name">#{{ name }} · {{ count }}</option></select><label v-if="ws.view === 'graph'"><input v-model="local" type="checkbox" :disabled="!ws.active" />仅当前笔记关联</label></div>
<p v-if="metadata.error" role="alert">{{ metadata.error }} <button class="rm-btn" @click="refreshMetadata">重试</button></p>
<p v-else-if="!metadata.ready" class="rm-tree-empty">关系索引准备中…</p>
<template v-else-if="ws.view === 'graph'">
<div class="rm-graph-canvas">
<svg :viewBox="`${camera.x} ${camera.y} ${camera.width} ${camera.width * .69}`" role="img" aria-label="笔记关系图谱,可点击节点打开笔记" @pointerdown="start" @pointermove="pan" @pointerup="drag = undefined" @pointercancel="drag = undefined" @wheel.prevent="zoom($event.deltaY > 0 ? 1.12 : .89)">
<line v-for="edge in graph.edges" :key="edge.from.path + '\0' + edge.to.path" :x1="edge.from.x" :y1="edge.from.y" :x2="edge.to.x" :y2="edge.to.y" />
<g v-for="node in graph.nodes" :key="node.path" :transform="`translate(${node.x} ${node.y})`" data-note tabindex="0" role="button" :aria-label="node.title" :class="{ current: node.path === ws.active }" @click="openNote(node.path)" @keydown.enter.prevent="openNote(node.path)" @keydown.space.prevent="openNote(node.path)"><title>{{ node.path }}</title><circle :r="Math.min(13, 5 + node.degree * 1.5)" /><text y="27" text-anchor="middle">{{ node.title.length > 16 ? node.title.slice(0,16) + '…' : node.title }}</text></g>
</svg>
<p v-if="!graph.nodes.length" class="rm-graph-empty">没有符合条件的笔记</p>
<div class="rm-graph-controls"><span>{{ graph.nodes.length }} 节点 · {{ graph.edges.length }} 关系</span><button class="rm-icon-btn" title="放大" @click="zoom(.8)">+</button><button class="rm-icon-btn" title="缩小" @click="zoom(1.25)">−</button><button class="rm-icon-btn" title="重置视图" @click="camera = { x: -420, y: -290, width: 840 }"><Icon name="expand" /></button></div>
</div>
<details v-if="missing.length" class="rm-missing-links"><summary>{{ missing.length }} 个链接目标尚不存在</summary><button v-for="link in missing" :key="link.path + link.line + link.target" @click="openReference(link.path, link.line)">{{ link.path }} → {{ link.target }} · 第 {{ link.line }} 行</button></details>
</template>
<template v-else>
<div class="rm-tags-cloud"><button :class="{ on: !tag }" @click="tag = ''">全部</button><button v-for="[name, count] in tags" :key="name" :class="{ on: tag === name }" @click="tag = name">#{{ name }} <small>{{ count }}</small></button></div>
<div class="rm-tag-results"><button v-for="note in visibleNotes" :key="note.path" @click="openNote(note.path)"><Icon name="note" /><div><strong>{{ note.title }}</strong><small>{{ note.path }}</small></div><span>{{ note.tags.map(tag => '#' + tag).join(' ') }}</span></button><p v-if="!visibleNotes.length">没有符合条件的笔记</p></div>
</template>
</section>
</template>
<style scoped>
.rm-knowledge-panel { flex: 1; overflow: auto; padding: 36px 40px; }
.rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; } .rm-eyebrow { font-size: 9px; letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-heading); font-size: 28px; font-weight: 600; margin: 12px 0; } p { font-size: 12px; color: var(--rm-text-muted); }
.rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 28px 0 20px; } label { display: flex; align-items: center; gap: 8px; font-size: 11px; } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: 12px; }
.rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: 10px; } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: 10px; color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
.rm-missing-links { margin-top: 20px; font-size: 12px; color: var(--rm-text-muted); } .rm-missing-links button { display: block; padding: 8px; background: none; border: 0; cursor: pointer; text-align: left; }
.rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: 12px; cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: 18px 12px; border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: 13px; font-weight: 500; } small { display: block; margin-top: 5px; font-size: 10px; color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: 10px; color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); }
@media(max-width:767px) { .rm-knowledge-panel { padding: 24px 16px; } h1 { font-size: 23px; } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: 10px; } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
</style>
+7
View File
@@ -9,6 +9,7 @@ import SearchPanel from './SearchPanel.vue'
import { createFolder, createNote, move, openSearch, ws } from './workspace' import { createFolder, createNote, move, openSearch, ws } from './workspace'
import { sidebarViews } from '../plugins/registry' import { sidebarViews } from '../plugins/registry'
import PluginMount from './PluginMount.vue' import PluginMount from './PluginMount.vue'
import { openDailyNote } from './templates'
const emit = defineEmits<{ logout: [] }>() const emit = defineEmits<{ logout: [] }>()
const rootDrop = ref(false) const rootDrop = ref(false)
@@ -50,6 +51,12 @@ function onRootDrop(e: DragEvent) {
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /><span>新建目录</span></button> <button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /><span>新建目录</span></button>
<button class="rm-icon-btn" title="导入" @click="ws.importing = ''"><Icon name="import" /><span>导入文档</span></button> <button class="rm-icon-btn" title="导入" @click="ws.importing = ''"><Icon name="import" /><span>导入文档</span></button>
</div> </div>
<nav class="rm-knowledge-nav" aria-label="知识工具">
<button :class="{ on: ws.view === 'graph' }" @click="ws.view = 'graph'; ws.sidebarOpen = false"><Icon name="graph" />关系图谱</button>
<button :class="{ on: ws.view === 'tags' }" @click="ws.view = 'tags'; ws.sidebarOpen = false"><Icon name="tag" />标签索引</button>
<button @click="openDailyNote"><Icon name="calendar" />今日日记</button>
<button @click="ws.templateOpen = true"><Icon name="noteAdd" />从模板新建</button>
</nav>
<div class="rm-tree-label">文件目录 <span>· VAULT EXPLORER</span></div> <div class="rm-tree-label">文件目录 <span>· VAULT EXPLORER</span></div>
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图"> <nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
<button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button> <button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import Icon from './Icon.vue'
import { settings } from './settings'
import { createFromTemplate } from './templates'
import { createNote, toast, ws } from './workspace'
const templates = computed(() => ws.entries.filter(entry => !entry.dir && entry.path.startsWith(settings.templatesFolder + '/') && entry.path.endsWith('.md')))
const selected = ref('')
const path = ref('未命名.md')
const busy = ref(false)
async function create() {
if (!path.value.endsWith('.md') || path.value.split('/').some(part => !part || part.startsWith('.') || /[\\\0]/.test(part))) { toast('请输入有效的 Markdown 笔记路径'); return }
busy.value = true
try { if (await createFromTemplate(path.value, selected.value)) ws.templateOpen = false }
catch (error) { toast('读取模板失败:' + (error instanceof Error ? error.message : '网络错误')) }
finally { busy.value = false }
}
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="!busy && (ws.templateOpen = false)" @keydown.esc.stop="!busy && (ws.templateOpen = false)">
<section class="rm-dialog rm-template-dialog" role="dialog" aria-label="从模板新建">
<div class="rm-dialog-head"><h2>从模板开始</h2><button class="rm-icon-btn" title="关闭" :disabled="busy" @click="ws.templateOpen = false"><Icon name="close" /></button></div>
<p class="rm-share-note">模板是 {{ settings.templatesFolder }}/ 下的 Markdown 文件。支持 {{ '{{date}}' }}、{{ '{{time}}' }} 和 {{ '{{title}}' }},图片沿用原模板资源。</p>
<form @submit.prevent="create">
<label>选择模板<select v-model="selected"><option value="">空白笔记</option><option v-for="template in templates" :key="template.path" :value="template.path">{{ template.path }}</option></select></label>
<label>笔记路径<input v-model="path" aria-label="笔记路径" required /></label>
<div class="rm-template-actions"><button class="rm-btn" type="button" :disabled="busy" @click="ws.templateOpen = false; createNote(settings.templatesFolder)">新建模板文件</button><button class="rm-btn rm-btn-primary" :disabled="busy">创建笔记</button></div>
</form>
</section>
</div>
</template>
<style scoped>
.rm-template-dialog { width: min(520px, calc(100vw - 24px)); } form { display: grid; gap: 20px; padding: 20px; } label { display: grid; gap: 9px; font-size: 12px; } input, select { padding: 10px; border: 1px solid var(--rm-divider); border-radius: 8px; background: var(--rm-bg); color: var(--rm-text); font: inherit; } .rm-template-actions { display: flex; justify-content: space-between; gap: 12px; }
</style>
+7 -1
View File
@@ -4,6 +4,8 @@ import { api, ApiError } from './api'
import { registerCommand } from './commands' import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue' import EditorPane from './EditorPane.vue'
import AppHeader from './AppHeader.vue' import AppHeader from './AppHeader.vue'
import KnowledgePanel from './KnowledgePanel.vue'
import TemplateDialog from './TemplateDialog.vue'
import EditorChrome from './EditorChrome.vue' import EditorChrome from './EditorChrome.vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import MobileToolbar from './MobileToolbar.vue' import MobileToolbar from './MobileToolbar.vue'
@@ -119,14 +121,18 @@ onBeforeUnmount(() => {
<button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''"><Icon name="search" :size="20" /></button> <button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''"><Icon name="search" :size="20" /></button>
<button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" :size="20" /></button> <button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" :size="20" /></button>
</header> </header>
<div v-show="ws.view === 'notes'" class="rm-notes-view">
<TabBar /> <TabBar />
<EditorChrome /> <EditorChrome />
<NoteBanner /> <NoteBanner />
<EditorPane /> <EditorPane />
<MobileToolbar /> <MobileToolbar />
</div>
<KnowledgePanel v-if="ws.view !== 'notes'" />
</main> </main>
<OutlinePanel v-if="ws.outlineOpen && ws.active" /> <OutlinePanel v-if="ws.outlineOpen && ws.active && ws.view === 'notes'" />
</div> </div>
<TemplateDialog v-if="ws.templateOpen" />
<QuickSwitcher v-if="ws.switcher !== null" /> <QuickSwitcher v-if="ws.switcher !== null" />
<SettingsDialog v-if="ws.settings" /> <SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" /> <ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
+4
View File
@@ -628,3 +628,7 @@ button {
.rm-outline { width: min(84vw, 320px); } .rm-outline { width: min(84vw, 320px); }
.rm-auth-card { padding: 28px 24px; } .rm-auth-card { padding: 28px 24px; }
} }
.rm-notes-view { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; }
.rm-knowledge-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 14px 12px 0; }
.rm-knowledge-nav button { display: flex; align-items: center; gap: 8px; padding: 9px 8px; font-size: 10px; border: 0; border-radius: 7px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-knowledge-nav button:hover, .rm-knowledge-nav .on { background: var(--rm-active); color: var(--rm-accent); }
+4 -1
View File
@@ -16,7 +16,7 @@ export const CONTENT_WIDTHS = [
export const SIDEBAR_WIDTH = { min: 200, max: 480 } export const SIDEBAR_WIDTH = { min: 200, max: 480 }
const DEFAULTS = { fontSize: 16, contentWidth: 760, sidebarWidth: 288, spellcheck: false, tabSize: 4 } const DEFAULTS = { dailyFolder: 'Daily', templatesFolder: 'Templates', dailyTemplate: '', fontSize: 16, contentWidth: 760, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> } export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
const CACHE_KEY = 'rawmark.settings' const CACHE_KEY = 'rawmark.settings'
@@ -27,6 +27,9 @@ export const settings = reactive<Settings>(sanitize(JSON.parse(localStorage.getI
function sanitize(raw: Record<string, unknown>): Settings { function sanitize(raw: Record<string, unknown>): Settings {
const pick = <T>(v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def) const pick = <T>(v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def)
return { return {
dailyFolder: typeof raw.dailyFolder === 'string' ? raw.dailyFolder : DEFAULTS.dailyFolder,
templatesFolder: typeof raw.templatesFolder === 'string' && raw.templatesFolder !== '' ? raw.templatesFolder : DEFAULTS.templatesFolder,
dailyTemplate: typeof raw.dailyTemplate === 'string' ? raw.dailyTemplate : DEFAULTS.dailyTemplate,
fontSize: typeof raw.fontSize === 'number' && Number.isFinite(raw.fontSize) && raw.fontSize > 0 ? raw.fontSize : DEFAULTS.fontSize, fontSize: typeof raw.fontSize === 'number' && Number.isFinite(raw.fontSize) && raw.fontSize > 0 ? raw.fontSize : DEFAULTS.fontSize,
shortcuts: sanitizeShortcuts(raw.shortcuts), shortcuts: sanitizeShortcuts(raw.shortcuts),
spellcheck: typeof raw.spellcheck === 'boolean' ? raw.spellcheck : DEFAULTS.spellcheck, spellcheck: typeof raw.spellcheck === 'boolean' ? raw.spellcheck : DEFAULTS.spellcheck,
+18
View File
@@ -0,0 +1,18 @@
import { localDate, renderTemplate } from '../markdown/template'
import { api } from './api'
import { joinPath } from './paths'
import { settings } from './settings'
import { createNoteAt, openNote, toast, ws } from './workspace'
export async function createFromTemplate(path: string, template: string) {
if (ws.entries.some(entry => entry.path === path)) { toast('目标笔记已存在'); return false }
const content = template ? renderTemplate((await api.readFile(template)).content, template, path) : ''
return createNoteAt(path, content)
}
export async function openDailyNote() {
const path = joinPath(settings.dailyFolder, localDate() + '.md')
if (ws.entries.some(entry => !entry.dir && entry.path === path)) { await openNote(path); return }
try { await createFromTemplate(path, settings.dailyTemplate) }
catch (error) { toast('创建日记失败:' + (error instanceof Error ? error.message : '网络错误')) }
}
+10 -6
View File
@@ -77,6 +77,8 @@ export const ws = reactive({
outlineOpen: layout.outlineOpen ?? false, outlineOpen: layout.outlineOpen ?? false,
sourceMode: false, sourceMode: false,
focusMode: false, focusMode: false,
view: 'notes' as 'notes' | 'graph' | 'tags',
templateOpen: false,
// 最近打开的笔记,最新的在前 // 最近打开的笔记,最新的在前
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[], recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
toasts: [] as Toast[], toasts: [] as Toast[],
@@ -476,6 +478,7 @@ export function revealPending(v: EditorView, path: string): boolean {
// ---- 标签页 ---- // ---- 标签页 ----
export async function openNote(path: string) { export async function openNote(path: string) {
ws.view = 'notes'
ws.sidebarOpen = false ws.sidebarOpen = false
if (tabOf(path)) { if (tabOf(path)) {
ws.active = path ws.active = path
@@ -666,8 +669,8 @@ export async function createNote(dir: string) {
const creating = new Map<string, Promise<boolean>>() const creating = new Map<string, Promise<boolean>>()
/** 在指定路径新建空笔记并打开,返回是否成功;不存在的上级目录由服务器创建 */ /** 在指定路径新建空笔记并打开,返回是否成功;不存在的上级目录由服务器创建 */
export async function createNoteAt(path: string): Promise<boolean> { export async function createNoteAt(path: string, content = ''): Promise<boolean> {
const done = createNoteFile(path) const done = createNoteFile(path, content)
creating.set(path, done) creating.set(path, done)
try { try {
return await done return await done
@@ -676,17 +679,18 @@ export async function createNoteAt(path: string): Promise<boolean> {
} }
} }
async function createNoteFile(path: string): Promise<boolean> { async function createNoteFile(path: string, content: string): Promise<boolean> {
const exists = new Set(ws.entries.map((e) => e.path)) const exists = new Set(ws.entries.map((e) => e.path))
const added = [path] const added = [path]
for (let d = dirname(path); d && !exists.has(d); d = dirname(d)) added.push(d) for (let d = dirname(path); d && !exists.has(d); d = dirname(d)) added.push(d)
ws.entries.push(...added.map((p, i) => ({ path: p, dir: i > 0, size: 0, mtime: Date.now() }))) ws.entries.push(...added.map((p, i) => ({ path: p, dir: i > 0, size: 0, mtime: Date.now() })))
return withOp(async () => { return withOp(async () => {
try { try {
const rev = await api.writeFile(path, '', '') const rev = await api.writeFile(path, content, '')
ws.tabs.push({ path, rev, status: 'ready', dirty: false }) ws.tabs.push({ path, rev, status: 'ready', dirty: false })
editorStates.set(path, buildState(path, '')) editorStates.set(path, buildState(path, content))
ws.active = path ws.active = path
ws.view = 'notes'
return true return true
} catch (err) { } catch (err) {
ws.entries = ws.entries.filter((e) => !added.includes(e.path)) ws.entries = ws.entries.filter((e) => !added.includes(e.path))
@@ -1063,7 +1067,7 @@ registerBuiltinCommands()
function registerBuiltinCommands() { function registerBuiltinCommands() {
for (const command of editorCommands) registerCommand({ ...command, for (const command of editorCommands) registerCommand({ ...command,
when: () => ws.settings === null && ws.active !== null && !!view, when: () => ws.view === 'notes' && ws.settings === null && ws.images === null && !ws.templateOpen && ws.active !== null && !!view,
run: () => { if (view) { command.run(view); view.focus() } }, run: () => { if (view) { command.run(view); view.focus() } },
}) })
const hasNote = () => ws.active !== null const hasNote = () => ws.active !== null
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { localDate, renderTemplate } from './template'
describe('Markdown 模板', () => {
it('按本地日期替换变量,资源相对新笔记重定位并保留代码示例', () => {
const now = new Date(2026, 9, 22, 8, 5)
const source = '# {{title}}\n{{date}} {{time}}\n![图](assets/a.png) [文档][ref]\n\n[ref]: ../docs/guide.md#标题\n\n`![](assets/a.png)`\n```\n![](assets/a.png)\n```'
expect(localDate(now)).toBe('2026-10-22')
const result = renderTemplate(source, 'Templates/日记.md', 'Daily/2026-10-22.md', now)
expect(result).toContain('# 2026-10-22\n2026-10-22 08:05')
expect(result).toContain('![图](../Templates/assets/a.png)')
expect(result).toContain('[ref]: ../docs/guide.md#%E6%A0%87%E9%A2%98')
expect(result).toContain('`![](assets/a.png)`\n```\n![](assets/a.png)\n```')
})
})
+24
View File
@@ -0,0 +1,24 @@
import { markdownParser } from './html'
export function localDate(now = new Date()): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
export function renderTemplate(content: string, template: string, target: string, now = new Date()): string {
const changes: { from: number; to: number; value: string }[] = []
markdownParser.parse(content).iterate({ enter(node) {
if (node.name !== 'URL' || !['Link', 'Image', 'LinkReference'].includes(node.node.parent?.name ?? '')) return
const raw = content.slice(node.from, node.to).replace(/^<|>$/g, '')
if (/^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(raw)) return
const url = new URL(raw.replace(/\\([!-/:-@[-`{-~])/g, '$1'), 'https://rawmark.local/' + template.split('/').map(encodeURIComponent).join('/'))
const parts = decodeURIComponent(url.pathname.slice(1)).split('/')
const base = target.slice(0, Math.max(0, target.lastIndexOf('/'))).split('/').filter(Boolean)
while (base.length && base[0] === parts[0]) { base.shift(); parts.shift() }
const relative = [...base.map(() => '..'), ...parts].map(part => encodeURIComponent(part)).join('/') + url.search + url.hash
changes.push({ from: node.from, to: node.to, value: relative })
} })
for (const change of changes.reverse()) content = content.slice(0, change.from) + change.value + content.slice(change.to)
const variables: Record<string, string> = { date: localDate(now), time: `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`, title: target.slice(target.lastIndexOf('/') + 1).replace(/\.md$/, '') }
return content.replace(/\{\{(date|time|title)\}\}/g, (_, key: string) => variables[key])
}