feat(tree): persist custom node icons and animate folder expansion
This commit is contained in:
1 parent
4a8902a3f6
commit
0861be681b
18 files changed
+1056
-72
No files matched your search
+3
-44
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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; } }
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}' }))
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user