feat(tree): persist custom node icons and animate folder expansion

This commit is contained in:
eezd committed 2026-10-24 18:13:42 +08:00
1 parent 4a8902a3f6
commit 0861be681b
18 files changed
+1056 -72

No files matched your search

+3 -44
View File
@@ -1,47 +1,6 @@
<script setup lang="ts">
// 线性图标(24×24 视框,描边绘制)
const paths = {
plus: 'M12 5v14M5 12h14',
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
expand: 'M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5',
graph: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM5 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM10 8l-4 8M14 8l4 8',
calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16',
tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01',
copy: 'M9 9h12v12H9zM5 15H3V3h12v2',
task: 'M3 3h7v7H3zM5 6l1 1 2-2M14 6h7M3 14h7v7H3zM14 18h7',
quote: 'M10 11H4V5h6v8a6 6 0 0 1-6 6M20 11h-6V5h6v8a6 6 0 0 1-6 6',
undo: 'm8 4-5 5 5 5M3 9h10a6 6 0 0 1 0 12h-2',
redo: 'm16 4 5 5-5 5M21 9H11a6 6 0 0 0 0 12h2',
print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z',
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',
menu: 'M4 6h16M4 12h16M4 18h16',
noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6',
import: 'M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5',
folderAdd: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6',
folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
image: 'M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 16l6-6 12 11M14 8h.01',
note: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6',
chevron: 'm9 6 6 6-6 6',
more: 'M12 6h.01M12 12h.01M12 18h.01',
close: 'M18 6 6 18M6 6l12 12',
pin: 'M16 9V3H8v6l-3 3v2h6v7h2v-7h6v-2z',
trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
key: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zM15.5 7.5l3 3L22 7l-3-3',
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
palette: 'M12 22a10 10 0 1 1 10-10c0 2.8-2.2 4-4 4h-2a2 2 0 0 0-1.5 3.3A1.7 1.7 0 0 1 12 22zM7.5 10.5h.01M12 7.5h.01M16.5 10.5h.01',
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
outline: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
source: 'm8 6-6 6 6 6m8-12 6 6-6 6',
command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 11v6M12 7h.01',
}
export type IconName = keyof typeof paths
import { iconPaths, type IconName } from './icons'
export type { IconName } from './icons'
defineProps<{ name: IconName; size?: number }>()
</script>
@@ -58,6 +17,6 @@ defineProps<{ name: IconName; size?: number }>()
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="paths[name]" />
<path :d="iconPaths[name]" />
</svg>
</template>
+5 -2
View File
@@ -3,8 +3,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { basename } from './paths'
import { commandTitle } from './commands'
import { dropDirectory, tree } from './tree'
import { dropDirectory, tree, treeIconTarget } from './tree'
import TreeNode from './TreeNode.vue'
import TreeIconPicker from './TreeIconPicker.vue'
import SearchPanel from './SearchPanel.vue'
import { createFolder, createNote, move, openSearch, ws } from './workspace'
import { sidebarViews } from '../plugins/registry'
@@ -89,6 +90,7 @@ onUnmounted(() => {
window.removeEventListener('blur', clearDrop)
window.removeEventListener('keydown', onDragKeydown)
clearDrop()
treeIconTarget.value = null
})
</script>
@@ -96,8 +98,8 @@ onUnmounted(() => {
<div class="rm-sidebar-inner">
<div class="rm-vault-heading">
<button class="rm-icon-btn" title="折叠目录" aria-label="折叠目录" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false"><Icon name="menu" /></button>
<button class="rm-tree-label" title="文件目录操作" aria-haspopup="true" :aria-expanded="rootMenu !== null" @click="rootContext" @contextmenu="rootContext">文件目录</button>
</div>
<button class="rm-tree-label" title="文件目录操作" aria-haspopup="true" :aria-expanded="rootMenu !== null" @click="rootContext" @contextmenu="rootContext">文件目录</button>
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
<button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button>
<button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">{{ pluginView.title }}</button>
@@ -121,6 +123,7 @@ onUnmounted(() => {
<button @click="runRootMenu('images')"><Icon name="image" />图片管理</button>
</div>
</Teleport>
<TreeIconPicker v-if="treeIconTarget" :key="treeIconTarget.path" :target="treeIconTarget" />
<div class="rm-sidebar-foot">
<button class="rm-foot-btn" :title="commandTitle('app.settings')" @click="ws.settings = 'index'"><Icon name="settings" />设置</button>
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Gemoji } from 'gemoji'
import Icon from './Icon.vue'
import { iconPaths, type IconName } from './icons'
import type { TreeIcon } from './api'
import { emojiCategories, emojiLabel, iconLabels, matchesEmoji } from './treeIconCatalog'
import { basename } from './paths'
import { treeIconTarget } from './tree'
import { saveTreeIcon } from './workspace'
const props = defineProps<{ target: { path: string; anchor: HTMLElement } }>()
const panel = ref<HTMLElement>()
const search = ref<HTMLInputElement>()
const position = ref({ left: 8, top: 8 })
const tab = ref<'emoji' | 'builtin'>('emoji')
const category = ref('Objects')
const query = ref('')
const emojis = ref<Gemoji[]>([])
const loading = ref(true)
const loadError = ref('')
const error = ref('')
const busy = ref(false)
const recent = ref<TreeIcon[]>([])
let resizeObserver: ResizeObserver | undefined
const RECENT_KEY = 'rawmark.recent-tree-icons'
const iconNames = Object.keys(iconPaths) as IconName[]
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
return emojis.value.filter(e => q ? matchesEmoji(e, q) : e.category === category.value)
})
const linear = computed(() => {
const q = query.value.trim().toLowerCase()
return iconNames.filter(name => (name + ' ' + iconLabels[name]).toLowerCase().includes(q))
})
const visibleRecent = computed(() => recent.value.filter(icon => icon.kind === tab.value))
const chosen = (icon: TreeIcon) => `${icon.kind}:${icon.value}`
function close() {
if (busy.value) return
treeIconTarget.value = null
props.target.anchor.focus({ preventScroll: true })
}
function place() {
if (!panel.value) return
const anchor = props.target.anchor.getBoundingClientRect()
const { width, height } = panel.value.getBoundingClientRect()
position.value = {
left: Math.max(8, Math.min(anchor.left, innerWidth - width - 8)),
top: Math.max(8, Math.min(anchor.bottom + 4, innerHeight - height - 8)),
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() }
if (event.key !== 'Tab' || !panel.value) return
const buttons = [...panel.value.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)')]
const index = buttons.indexOf(document.activeElement as HTMLElement)
if (event.shiftKey && index <= 0) { event.preventDefault(); buttons.at(-1)?.focus() }
else if (!event.shiftKey && index === buttons.length - 1) { event.preventDefault(); buttons[0]?.focus() }
}
async function loadEmojis() {
loading.value = true
loadError.value = ''
try { emojis.value = (await import('gemoji')).gemoji }
catch (err) { loadError.value = '表情加载失败:' + (err instanceof Error ? err.message : String(err)) }
finally { loading.value = false }
}
async function save(icon: TreeIcon | null) {
if (busy.value) return
busy.value = true
error.value = ''
try {
await saveTreeIcon(props.target.path, icon)
if (icon) {
recent.value = [icon, ...recent.value.filter(item => chosen(item) !== chosen(icon))].slice(0, 24)
try { localStorage.setItem(RECENT_KEY, JSON.stringify(recent.value)) }
catch (err) { console.warn('保存最近图标失败', err) }
}
busy.value = false
close()
} catch (err) {
error.value = '图标保存失败:' + (err instanceof Error ? err.message : String(err))
} finally { busy.value = false }
}
onMounted(() => {
try {
const data: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]')
if (Array.isArray(data)) recent.value = data.filter((item): item is TreeIcon => item && typeof item === 'object' && typeof item.value === 'string' && (item.kind === 'emoji' || item.kind === 'builtin' && iconNames.includes(item.value))).slice(0, 24)
} catch (err) { console.warn('读取最近图标失败', err) }
place()
search.value?.focus({ preventScroll: true })
resizeObserver = new ResizeObserver(place)
resizeObserver.observe(panel.value!)
window.addEventListener('resize', place)
document.addEventListener('keydown', onKeydown, true)
void loadEmojis()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', place)
document.removeEventListener('keydown', onKeydown, true)
})
</script>
<template>
<Teleport to="body">
<div class="rm-menu-backdrop rm-tree-icon-backdrop" @click="close" @contextmenu.prevent="close" />
<section ref="panel" class="rm-tree-icon-picker" role="dialog" aria-modal="true" aria-label="修改图标" :aria-busy="busy" :style="{ left: position.left + 'px', top: position.top + 'px' }">
<header><span class="rm-tree-icon-title" :title="target.path">{{ basename(target.path) }}</span><button class="rm-icon-btn" title="关闭图标选择" aria-label="关闭图标选择" :disabled="busy" @click="close"><Icon name="close" /></button></header>
<div class="rm-tree-icon-tabs" role="tablist" aria-label="图标类型">
<button role="tab" :aria-selected="tab === 'emoji'" :disabled="busy" @click="tab = 'emoji'">表情</button>
<button role="tab" :aria-selected="tab === 'builtin'" :disabled="busy" @click="tab = 'builtin'">线性图标</button>
</div>
<input ref="search" v-model="query" class="rm-input" placeholder="搜索图标(中文 / English)" aria-label="搜索图标" :disabled="busy" />
<nav v-if="tab === 'emoji'" class="rm-tree-icon-categories" aria-label="表情分类">
<button v-for="item in emojiCategories" :key="item.id" class="rm-emoji" :title="item.label" :aria-label="item.label" :aria-pressed="category === item.id && !query" :disabled="busy" @click="category = item.id; query = ''">{{ item.icon }}</button>
</nav>
<div class="rm-tree-icon-results">
<template v-if="!query && visibleRecent.length">
<p>最近使用</p>
<div class="rm-tree-icon-grid">
<button v-for="icon in visibleRecent" :key="chosen(icon)" :class="{ 'rm-emoji': icon.kind === 'emoji' }" :title="icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value" :aria-label="'最近:' + (icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value)" :disabled="busy" @click="save(icon)">
<Icon v-if="icon.kind === 'builtin'" :name="icon.value" :size="20" /><template v-else>{{ icon.value }}</template>
</button>
</div>
</template>
<template v-if="tab === 'emoji'">
<p v-if="loading">正在加载表情…</p>
<div v-else-if="loadError" role="alert">{{ loadError }} <button class="rm-btn" @click="loadEmojis">重试</button></div>
<template v-else>
<p>{{ query ? '搜索结果' : emojiCategories.find(c => c.id === category)?.label }}</p>
<div class="rm-tree-icon-grid">
<button v-for="emoji in filtered" :key="emoji.emoji" class="rm-emoji" :title="emojiLabel(emoji)" :aria-label="emojiLabel(emoji)" :disabled="busy" @click="save({ kind: 'emoji', value: emoji.emoji })">{{ emoji.emoji }}</button>
</div>
<p v-if="!filtered.length">没有找到图标,试试其他关键词</p>
</template>
</template>
<template v-else>
<p>线性图标</p>
<div class="rm-tree-icon-grid">
<button v-for="name in linear" :key="name" :title="iconLabels[name]" :aria-label="iconLabels[name]" :disabled="busy" @click="save({ kind: 'builtin', value: name })"><Icon :name="name" :size="20" /></button>
</div>
<p v-if="!linear.length">没有找到图标,试试其他关键词</p>
</template>
</div>
<p v-if="error" class="rm-tree-icon-error" role="alert">{{ error }}</p>
<footer><button class="rm-btn" :disabled="busy" @click="save(null)">{{ busy ? '正在保存…' : '恢复默认图标' }}</button></footer>
</section>
</Teleport>
</template>
<style scoped>
.rm-tree-icon-backdrop { z-index: 46; }
.rm-tree-icon-picker { position: fixed; z-index: 47; box-sizing: border-box; display: flex; flex-direction: column; gap: var(--rm-space-2); width: 360px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); padding: var(--rm-space-3); border: 1px solid var(--rm-divider); border-radius: 14px; background: color-mix(in srgb, var(--rm-bg) 96%, transparent); backdrop-filter: blur(24px); box-shadow: var(--rm-shadow); font-size: var(--rm-ui-font-size); }
header { flex: none; display: flex; align-items: center; gap: var(--rm-space-2); }
.rm-tree-icon-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-tree-icon-tabs { display: flex; border-bottom: 1px solid var(--rm-divider); }
.rm-tree-icon-tabs button { flex: 1; min-height: var(--rm-control-size); border: 0; border-bottom: 2px solid transparent; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-tree-icon-tabs [aria-selected="true"] { color: var(--rm-text); border-bottom-color: var(--rm-accent); }
.rm-tree-icon-categories { display: flex; flex-wrap: wrap; gap: 2px; }
.rm-tree-icon-categories button { flex: 1; min-width: 28px; min-height: var(--rm-control-size); border: 0; border-radius: 4px; background: none; font-size: 18px; cursor: pointer; }
.rm-tree-icon-results { flex: 1 1 auto; min-height: 0; max-height: 300px; overflow: auto; overscroll-behavior: contain; }
.rm-tree-icon-results p { margin: var(--rm-space-2) 0; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-tree-icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 3px; }
.rm-tree-icon-grid button { display: flex; align-items: center; justify-content: center; min-width: 0; height: 36px; padding: 0; border: 0; border-radius: 4px; background: none; color: var(--rm-text); font-size: 22px; cursor: pointer; }
.rm-tree-icon-grid button:hover, .rm-tree-icon-categories button:hover, .rm-tree-icon-categories [aria-pressed="true"] { background: var(--rm-hover); }
.rm-tree-icon-error { margin: 0; max-height: 80px; overflow: auto; color: var(--rm-danger); overflow-wrap: anywhere; }
footer { flex: none; display: flex; justify-content: flex-end; }
@media (pointer: coarse) { .rm-tree-icon-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); } .rm-tree-icon-grid button, .rm-tree-icon-categories button, .rm-tree-icon-tabs button { min-height: 44px; } }
</style>
+23 -6
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { basename, dirname, inAssetDir, isImage, joinPath } from './paths'
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, treeIconTarget, type TreeNode } from './tree'
import { batchFiles, exportFiles } from './batch'
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
@@ -78,7 +78,7 @@ function commitRename() {
if (target && target !== props.node.path) void move(props.node.path, target)
}
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'delete' | 'export') {
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'icon' | 'rename' | 'move' | 'delete' | 'export') {
menuOpen.value = false
const path = props.node.path
if (action === 'note' || action === 'folder') {
@@ -86,12 +86,18 @@ function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'm
void (action === 'note' ? createNote(path) : createFolder(path))
} else if (action === 'import') ws.importing = path
else if (action === 'images') { ws.imagesAll = false; ws.images = path }
else if (action === 'icon') editIcon()
else if (action === 'rename') ws.renaming = path
else if (action === 'move') ws.moving = [...selectedPaths]
else if (action === 'export') void exportFiles([...selectedPaths])
else void batchFiles('delete', [...selectedPaths])
}
function editIcon(event?: MouseEvent) {
const anchor = event?.currentTarget instanceof HTMLElement ? event.currentTarget : row.value?.querySelector<HTMLElement>('.rm-tree-icon')
if (anchor) treeIconTarget.value = { path: props.node.path, anchor }
}
function showMenu() {
contextSelection(props.node.path)
menuOpen.value = true
@@ -152,6 +158,7 @@ function onDrop(e: DragEvent) {
:draggable="!isRenaming && !assetDir"
role="treeitem"
:aria-selected="selectedPaths.has(node.path)"
:aria-expanded="node.dir ? isOpen : undefined"
@click="activate"
@contextmenu.prevent.stop="showMenu"
@touchstart.passive="onTouchStart"
@@ -163,6 +170,12 @@ function onDrop(e: DragEvent) {
@drop="onDrop"
>
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="16" /></span>
<button v-if="!isRenaming" type="button" class="rm-tree-icon" :class="{ 'rm-emoji': node.icon?.kind === 'emoji' || (!node.icon && !image) }" :aria-label="'修改图标:' + node.name" title="修改图标" @click.stop="editIcon" @touchstart.stop @contextmenu.prevent.stop="editIcon">
<Icon v-if="node.icon?.kind === 'builtin'" :name="node.icon.value" :size="18" />
<template v-else-if="node.icon?.kind === 'emoji'">{{ node.icon.value }}</template>
<Icon v-else-if="image" name="image" :size="18" />
<template v-else>{{ node.dir ? '📂' : '📄' }}</template>
</button>
<input
v-if="isRenaming"
ref="input"
@@ -173,7 +186,6 @@ function onDrop(e: DragEvent) {
@keydown.esc.prevent="cancelRename"
@blur="commitRename"
/>
<Icon v-if="image" name="image" :size="18" />
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
<button v-if="!isRenaming" class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
<Icon name="more" />
@@ -189,15 +201,20 @@ function onDrop(e: DragEvent) {
<button @click="runMenu('import')"><Icon name="import" />导入</button>
</template>
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
<button v-if="selectedPaths.size === 1" @click="runMenu('icon')"><Icon name="palette" />修改图标</button>
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div>
</Teleport>
<ul v-if="node.dir && isOpen" class="rm-tree-list">
<TreeNode v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" />
</ul>
<Transition name="rm-tree-expand" @before-leave="($el: Element) => $el.setAttribute('inert', '')" @leave-cancelled="($el: Element) => $el.removeAttribute('inert')">
<div v-if="node.dir && isOpen" class="rm-tree-children">
<ul class="rm-tree-list">
<TreeNode v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" />
</ul>
</div>
</Transition>
</li>
</template>
+6
View File
@@ -1,10 +1,15 @@
import type { IconName } from './icons'
// REST API 客户端,与服务端 internal/api 对应
export type TreeIcon = { kind: 'emoji'; value: string } | { kind: 'builtin'; value: IconName }
export interface Entry {
path: string
dir: boolean
size: number
mtime: number
icon?: TreeIcon
}
export interface ImportResult {
@@ -144,6 +149,7 @@ export const api = {
exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }),
metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')),
setTreeIcon: (path: string, icon: TreeIcon | null) => request('PUT', '/api/v1/tree-icon', { json: { path, icon } }),
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
+13 -2
View File
@@ -156,7 +156,7 @@ button {
.rm-tree-row { -webkit-touch-callout: none; }
.rm-tree-row.active { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); color: var(--rm-text); font-weight: 500; }
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform 0.15s; }
.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-tree-chevron.open { transform: rotate(90deg); }
.rm-tree-chevron.hidden { visibility: hidden; }
.rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -631,7 +631,7 @@ button {
/* 轻玻璃工作台 */
.rm-vault-heading { display: flex; align-items: center; padding: 2px 4px; }
.rm-tree-label { flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: left; cursor: pointer; }
.rm-tree-label { margin-left: auto; flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: right; cursor: pointer; }
.rm-tree-label:hover { background: var(--rm-hover); }
.rm-tree { padding: 0 var(--rm-space-2) var(--rm-space-2); }
.rm-tree-row { height: var(--rm-control-compact); font-size: inherit; gap: var(--rm-space-1); }
@@ -713,3 +713,14 @@ button {
@media (max-width: 767px), (pointer: coarse) {
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
}
/* 文件树子层按实际内容高度展开;退出期间不能继续点击或聚焦。 */
.rm-tree-children { display: grid; grid-template-rows: 1fr; }
.rm-tree-children > .rm-tree-list { min-height: 0; overflow: hidden; }
.rm-tree-expand-enter-active, .rm-tree-expand-leave-active { transition: grid-template-rows var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-tree-expand-enter-from, .rm-tree-expand-leave-to { grid-template-rows: 0fr; opacity: 0; }
.rm-tree-expand-leave-active { pointer-events: none; }
.rm-emoji { font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; font-weight: normal; font-style: normal; }
.rm-tree-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 24px; padding: 0; border: 0; border-radius: 4px; background: none; color: inherit; font-size: 17px; line-height: 1; cursor: pointer; }
.rm-tree-icon:hover { background: var(--rm-hover); }
@media (pointer: coarse) { .rm-tree-icon { width: 28px; height: 44px; } }
+43
View File
@@ -0,0 +1,43 @@
// 线性图标(24×24 视框,描边绘制)
export const iconPaths = {
plus: 'M12 5v14M5 12h14',
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
expand: 'M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5',
graph: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM5 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM10 8l-4 8M14 8l4 8',
calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16',
tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01',
copy: 'M9 9h12v12H9zM5 15H3V3h12v2',
task: 'M3 3h7v7H3zM5 6l1 1 2-2M14 6h7M3 14h7v7H3zM14 18h7',
quote: 'M10 11H4V5h6v8a6 6 0 0 1-6 6M20 11h-6V5h6v8a6 6 0 0 1-6 6',
undo: 'm8 4-5 5 5 5M3 9h10a6 6 0 0 1 0 12h-2',
redo: 'm16 4 5 5-5 5M21 9H11a6 6 0 0 0 0 12h2',
print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z',
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',
menu: 'M4 6h16M4 12h16M4 18h16',
noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6',
import: 'M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5',
folderAdd: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6',
folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
image: 'M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 16l6-6 12 11M14 8h.01',
note: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6',
chevron: 'm9 6 6 6-6 6',
more: 'M12 6h.01M12 12h.01M12 18h.01',
close: 'M18 6 6 18M6 6l12 12',
pin: 'M16 9V3H8v6l-3 3v2h6v7h2v-7h6v-2z',
trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
key: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zM15.5 7.5l3 3L22 7l-3-3',
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
palette: 'M12 22a10 10 0 1 1 10-10c0 2.8-2.2 4-4 4h-2a2 2 0 0 0-1.5 3.3A1.7 1.7 0 0 1 12 22zM7.5 10.5h.01M12 7.5h.01M16.5 10.5h.01',
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
outline: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
source: 'm8 6-6 6 6 6m8-12 6 6-6 6',
command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 11v6M12 7h.01',
}
export type IconName = keyof typeof iconPaths
+6 -3
View File
@@ -1,5 +1,5 @@
import { computed, reactive, ref, watch } from 'vue'
import type { Entry } from './api'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import type { Entry, TreeIcon } from './api'
import { basename, dirname, hiddenAssetPath } from './paths'
import { rangePaths } from './selection'
import { ws } from './workspace'
@@ -8,6 +8,7 @@ export interface TreeNode {
name: string
path: string
dir: boolean
icon?: TreeIcon
children: TreeNode[]
}
@@ -18,7 +19,7 @@ function build(entries: Entry[]): TreeNode[] {
const dirs = new Map<string, TreeNode>()
// 先按路径排序,保证父目录先于子项出现
for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) {
const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, children: [] }
const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, icon: e.icon, children: [] }
if (e.dir) dirs.set(e.path, node)
;(dirs.get(dirname(e.path))?.children ?? root).push(node)
}
@@ -32,6 +33,8 @@ function build(entries: Entry[]): TreeNode[] {
export const tree = computed(() => build(ws.entries.filter((entry) => !hiddenAssetPath(entry.path))))
export const treeIconTarget = shallowRef<{ path: string; anchor: HTMLElement } | null>(null)
// null 表示无接收目标,空字符串表示根目录;文件行指向它的直接父目录。
export const dropDirectory = ref<string | null>(null)
+48
View File
@@ -0,0 +1,48 @@
import type { Gemoji } from 'gemoji'
import type { IconName } from './icons'
export const iconLabels: Record<IconName, string> = {
plus: '加号', eye: '查看', expand: '展开', graph: '关系', calendar: '日历', tag: '标签', copy: '复制',
task: '任务', quote: '引用', undo: '撤销', redo: '重做', print: '打印', grid: '网格', menu: '菜单',
noteAdd: '新建笔记', import: '导入', folderAdd: '新建文件夹', folder: '文件夹', image: '图片', note: '文档',
chevron: '箭头', more: '更多', close: '关闭', pin: '固定', trash: '回收站', logout: '退出', restore: '恢复',
edit: '编辑', search: '搜索', link: '链接', key: '钥匙', settings: '设置', palette: '调色板', user: '用户',
outline: '大纲', source: '代码', command: '命令', info: '信息',
}
export const emojiCategories = [
{ id: 'Smileys & Emotion', label: '表情', icon: '😀' },
{ id: 'People & Body', label: '人物', icon: '👋' },
{ id: 'Animals & Nature', label: '自然', icon: '🌿' },
{ id: 'Food & Drink', label: '食物', icon: '🍎' },
{ id: 'Travel & Places', label: '旅行', icon: '🚀' },
{ id: 'Activities', label: '活动', icon: '⚽' },
{ id: 'Objects', label: '物品', icon: '💡' },
{ id: 'Symbols', label: '符号', icon: '❤️' },
{ id: 'Flags', label: '旗帜', icon: '🏳️' },
]
// 常见目录用途的中文检索词;其余仍可按 Emoji 和原有英文名称搜索。
const keywords: Record<string, string> = {
'📂': '文件夹 目录 打开', '📁': '文件夹 目录', '📄': '文档 笔记 文章', '📝': '笔记 编辑 记录',
'📃': '文档 文章', '📋': '清单 剪贴板', '📚': '书籍 知识 学习', '📖': '阅读 书籍', '📕': '书籍 红书',
'📗': '书籍 绿书', '📘': '书籍 蓝书', '📙': '书籍 橙书', '📓': '笔记本', '📒': '账本 笔记本',
'📔': '笔记本 日记', '📅': '日历 日期', '🗓️': '日历 计划', '✅': '完成 任务 对勾', '☑️': '勾选 任务',
'⭐': '星星 收藏', '🌟': '星星 重要', '❤️': '爱心 喜欢', '🔥': '火 热门', '💡': '灯泡 灵感 想法',
'📌': '固定 图钉', '🔗': '链接', '🔒': '锁 安全', '🔑': '钥匙 密钥', '⚙️': '设置 齿轮',
'🛠️': '工具 开发', '💻': '电脑 编程 代码', '🖥️': '电脑 显示器', '📱': '手机', '🖼️': '图片 相册',
'🎨': '艺术 设计 调色板', '🎵': '音乐', '🎬': '电影 视频', '📷': '相机 摄影', '🎮': '游戏',
'🏠': '房子 主页 家', '🏡': '家 房子', '🚀': '火箭 项目 发布', '💼': '工作 公文包', '🎯': '目标',
'🌍': '地球 世界', '🌏': '地球 亚洲', '🌱': '幼苗 成长', '🌿': '植物 叶子 自然', '🌸': '花 樱花',
'☀️': '太阳 晴天', '🌙': '月亮 夜晚', '🐱': '猫 动物', '🐶': '狗 动物', '🍎': '苹果 食物',
'☕': '咖啡 饮料', '😀': '笑脸 开心 表情', '😊': '微笑 开心 表情', '🤔': '思考 想法', '👍': '赞 好',
'⚠️': '警告 注意', '❗': '感叹号 重要', '❓': '问号 问题', '💰': '钱 财务', '🎓': '毕业 学习',
}
export function emojiLabel(emoji: Gemoji): string {
return keywords[emoji.emoji] ?? emoji.description
}
export function matchesEmoji(emoji: Gemoji, query: string): boolean {
return [emoji.emoji, emoji.description, ...emoji.names, ...emoji.tags, keywords[emoji.emoji] ?? '', emojiCategories.find(c => c.id === emoji.category)?.label ?? ''].join(' ').toLowerCase().includes(query)
}
+132
View File
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Sidebar from './Sidebar.vue'
import { expanded, selectedPaths, treeIconTarget } from './tree'
import * as workspace from './workspace'
import { api } from './api'
let app: App
let host: HTMLElement
beforeEach(async () => {
vi.spyOn(workspace, 'saveTreeIcon').mockResolvedValue()
vi.spyOn(workspace, 'openNote').mockResolvedValue()
vi.spyOn(workspace, 'move').mockResolvedValue({ ok: true })
localStorage.removeItem('rawmark.recent-tree-icons')
Object.assign(workspace.ws, { searchOpen: false, active: '', image: null, renaming: null, entries: [
{ path: 'folder', dir: true }, { path: 'folder/a.md', dir: false },
{ path: 'folder/b.md', dir: false }, { path: 'folder/c.md', dir: false },
].map(e => ({ ...e, size: 0, mtime: 0 })) })
expanded.clear()
selectedPaths.clear()
treeIconTarget.value = null
host = document.createElement('div')
document.body.append(host)
app = createApp(Sidebar)
app.mount(host)
await nextTick()
})
afterEach(() => { app.unmount(); host.remove(); vi.restoreAllMocks() })
const row = (name: string) => [...host.querySelectorAll<HTMLElement>('.rm-tree-row')].find(e => e.querySelector('.rm-tree-name')?.textContent === name)!
const click = async (element: Element, init: MouseEventInit = {}) => { element.dispatchEvent(new MouseEvent('click', { bubbles: true, ...init })); await nextTick() }
const button = (name: string) => document.querySelector<HTMLElement>(`[aria-label="${name}"]`)!
async function picker() {
await click(row('folder').querySelector('.rm-tree-icon')!)
await vi.waitFor(() => expect(button('搜索图标')).not.toBeNull())
}
describe('文件树图标操作', () => {
it('保留右侧目录菜单,点击默认图标不折叠目录或改变多选', async () => {
expect(host.querySelector('.rm-vault-heading .rm-tree-label')?.textContent).toBe('文件目录')
expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📂')
await click(row('folder'))
expect(row('a').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📄')
await click(row('a'), { ctrlKey: true })
const selected = [...selectedPaths]
await picker()
expect(expanded.has('folder')).toBe(true)
expect([...selectedPaths]).toEqual(selected)
expect(workspace.openNote).not.toHaveBeenCalled()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await nextTick()
expect(treeIconTarget.value).toBeNull()
expect(document.activeElement).toBe(row('folder').querySelector('.rm-tree-icon'))
})
it('中文搜索表情并保存到当前节点,最近使用只记成功操作', async () => {
await picker()
const input = button('搜索图标') as HTMLInputElement
input.value = '文件夹'
input.dispatchEvent(new Event('input', { bubbles: true }))
await vi.waitFor(() => expect(button('文件夹 目录 打开')).not.toBeNull())
await click(button('文件夹 目录 打开'))
await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
expect(workspace.saveTreeIcon).toHaveBeenCalledExactlyOnceWith('folder', { kind: 'emoji', value: '📂' })
expect(JSON.parse(localStorage.getItem('rawmark.recent-tree-icons')!)).toEqual([{ kind: 'emoji', value: '📂' }])
})
it('保存失败保留弹窗与明确错误,恢复默认成功后关闭', async () => {
vi.mocked(workspace.saveTreeIcon).mockRejectedValueOnce(new Error('磁盘只读'))
await picker()
await click([...document.querySelectorAll('button')].find(e => e.textContent === '恢复默认图标')!)
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain('磁盘只读'))
expect(treeIconTarget.value?.path).toBe('folder')
expect(localStorage.getItem('rawmark.recent-tree-icons')).toBeNull()
await click([...document.querySelectorAll('button')].find(e => e.textContent === '恢复默认图标')!)
await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
expect(workspace.saveTreeIcon).toHaveBeenLastCalledWith('folder', null)
})
it('线性图标可搜索,右键入口选择同一节点', async () => {
row('folder').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
await nextTick()
await click([...document.querySelectorAll('.rm-tree-menu button')].find(e => e.textContent === '修改图标')!)
await click([...document.querySelectorAll('[role="tab"]')].find(e => e.textContent === '线性图标')!)
const input = button('搜索图标') as HTMLInputElement
input.value = '任务'; input.dispatchEvent(new Event('input', { bubbles: true })); await nextTick()
expect(document.querySelectorAll('.rm-tree-icon-grid button')).toHaveLength(1)
await click(button('任务'))
expect(workspace.saveTreeIcon).toHaveBeenCalledWith('folder', { kind: 'builtin', value: 'task' })
})
it('Ctrl 和 Shift 仍按可见行选择,快速展开/收起保留最新状态', async () => {
await click(row('folder'))
await click(row('a'))
await click(row('c'), { shiftKey: true })
expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/b.md', 'folder/c.md'])
await click(row('b'), { ctrlKey: true })
expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/c.md'])
await click(row('folder'))
await click(row('folder'))
await vi.waitFor(() => expect(row('c')).toBeDefined())
expect(expanded.has('folder')).toBe(true)
await click(row('folder'))
await vi.waitFor(() => expect(row('a')).toBeUndefined())
expect(selectedPaths.has('folder/a.md')).toBe(false)
})
it('从图标按住拖动仍使用整行的拖放契约,不能误开选择框', async () => {
const setData = vi.fn()
const event = new Event('dragstart', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { setData, effectAllowed: '' } })
row('folder').querySelector('.rm-tree-icon')!.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
expect(setData).toHaveBeenCalledExactlyOnceWith('application/x-rawmark-path', 'folder')
expect(treeIconTarget.value).toBeNull()
})
it('持久属性直接来自目录 API,不维护第二份图标映射', async () => {
workspace.ws.entries[0].icon = { kind: 'builtin', value: 'task' }
await nextTick()
expect(row('folder').querySelector('.rm-tree-icon svg')).not.toBeNull()
workspace.ws.entries[0].icon = { kind: 'emoji', value: '📚' }
await nextTick()
expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📚')
})
})
it('图标请求沿用认证、禁缓存和 null 重置契约', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'))
await api.setTreeIcon('folder/a.md', null)
expect(fetch).toHaveBeenCalledWith('/api/v1/tree-icon', expect.objectContaining({ method: 'PUT', credentials: 'same-origin', cache: 'no-store', body: '{"path":"folder/a.md","icon":null}' }))
})
+7 -1
View File
@@ -8,7 +8,7 @@ import { editorCommands } from '../editor/shortcuts'
import { refreshImages } from '../editor/livepreview'
import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from '../editor'
import { handleShortcut, registerCommand } from './commands'
import { api, ApiError, encodePath, type Entry } from './api'
import { api, ApiError, encodePath, type Entry, type TreeIcon } from './api'
import { drafts } from './drafts'
import { refreshMetadata } from './metadata'
import { connectEvents, type VaultEvent } from './events'
@@ -663,6 +663,12 @@ async function withOp<T>(fn: () => Promise<T>): Promise<T> {
}
}
export async function saveTreeIcon(path: string, icon: TreeIcon | null) {
await withOp(() => api.setTreeIcon(path, icon))
try { await refreshTree() }
catch (err) { toast('图标已保存,目录刷新失败:' + errorText(err)) }
}
export async function createNote(dir: string) {
const path = uniqueName(dir, '未命名', NOTE_EXT)
ws.renaming = path