feat(knowledge): add graph tags daily notes and Markdown templates
This commit is contained in:
1 parent
a923c2dfb9
commit
2a35a392b7
11 files changed
+210
-11
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 />
|
||||||
</main>
|
|
||||||
<OutlinePanel v-if="ws.outlineOpen && ws.active" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<KnowledgePanel v-if="ws.view !== 'notes'" />
|
||||||
|
</main>
|
||||||
|
<OutlinePanel v-if="ws.outlineOpen && ws.active && ws.view === 'notes'" />
|
||||||
|
</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" />
|
||||||
|
|||||||
@@ -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); }
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 : '网络错误')) }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 [文档][ref]\n\n[ref]: ../docs/guide.md#标题\n\n``\n```\n\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('')
|
||||||
|
expect(result).toContain('[ref]: ../docs/guide.md#%E6%A0%87%E9%A2%98')
|
||||||
|
expect(result).toContain('``\n```\n\n```')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user