diff --git a/web/src/app/ImageDialog.vue b/web/src/app/ImageDialog.vue
index dfc7b5d..57ad38c 100644
--- a/web/src/app/ImageDialog.vue
+++ b/web/src/app/ImageDialog.vue
@@ -105,6 +105,7 @@ async function trash() {
+
+
+
+
+
diff --git a/web/src/app/app.css b/web/src/app/app.css
index 5680cd1..5799fe5 100644
--- a/web/src/app/app.css
+++ b/web/src/app/app.css
@@ -544,3 +544,5 @@ button {
.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.rm-shortcut-chip { display: inline-flex; align-items: center; gap: 3px; padding-left: 6px; border: 1px solid var(--rm-divider); border-radius: 5px; }
.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); }
+
+.rm-context-menu { position: fixed; right: auto; width: max-content; }
diff --git a/web/src/app/paths.test.ts b/web/src/app/paths.test.ts
new file mode 100644
index 0000000..fcb4c92
--- /dev/null
+++ b/web/src/app/paths.test.ts
@@ -0,0 +1,18 @@
+import { expect, it } from 'vitest'
+import { hiddenAssetPath, imageInFolder } from './paths'
+
+it('图片管理仅包含当前目录和直属附件子树', () => {
+ expect(imageInFolder('项目/a.png', '项目')).toBe(true)
+ expect(imageInFolder('项目/笔记.assets/嵌套/a.png', '项目')).toBe(true)
+ expect(imageInFolder('项目/assets/a.png', '项目')).toBe(true)
+ expect(imageInFolder('项目/子目录/assets/a.png', '项目')).toBe(false)
+ expect(imageInFolder('项目二/a.png', '项目')).toBe(false)
+ expect(imageInFolder('项目/a.md', '项目')).toBe(false)
+ expect(imageInFolder('assets/a.png', '')).toBe(true)
+})
+
+it('导航隐藏附件目录的完整子树而不误伤相似名称', () => {
+ expect(hiddenAssetPath('项目/assets/a.png')).toBe(true)
+ expect(hiddenAssetPath('项目/a.assets')).toBe(true)
+ expect(hiddenAssetPath('项目/myassets/a.png')).toBe(false)
+})
diff --git a/web/src/app/paths.ts b/web/src/app/paths.ts
index e588e51..d467420 100644
--- a/web/src/app/paths.ts
+++ b/web/src/app/paths.ts
@@ -46,3 +46,16 @@ export function resolveAssetUrl(notePath: string, src: string): string {
}
return '/api/v1/raw/' + encodePath(parts.join('/'))
}
+
+// 导航隐藏普通共享附件与笔记附件,不改变这些目录的读写契约。
+export function hiddenAssetPath(path: string): boolean {
+ return path.split('/').some((part) => part === 'assets' || part.endsWith('.assets'))
+}
+
+export function imageInFolder(path: string, dir: string): boolean {
+ if (!isImage(path) || (dir && !path.startsWith(dir + '/'))) return false
+ const relative = dir ? path.slice(dir.length + 1) : path
+ if (!relative.includes('/')) return true
+ const first = relative.split('/')[0]
+ return first === 'assets' || first.endsWith('.assets')
+}
diff --git a/web/src/app/tree.ts b/web/src/app/tree.ts
index d6cd5dc..4da7a14 100644
--- a/web/src/app/tree.ts
+++ b/web/src/app/tree.ts
@@ -1,6 +1,6 @@
import { computed, reactive, watch } from 'vue'
import type { Entry } from './api'
-import { basename, dirname } from './paths'
+import { basename, dirname, hiddenAssetPath } from './paths'
import { ws } from './workspace'
export interface TreeNode {
@@ -29,7 +29,7 @@ function build(entries: Entry[]): TreeNode[] {
return root
}
-export const tree = computed(() => build(ws.entries))
+export const tree = computed(() => build(ws.entries.filter((entry) => !hiddenAssetPath(entry.path))))
const EXPANDED_KEY = 'rawmark.expanded'
export const expanded = reactive(new Set
(JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? '[]') as string[]))
diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts
index a7d81a6..a5b299b 100644
--- a/web/src/app/workspace.ts
+++ b/web/src/app/workspace.ts
@@ -68,6 +68,7 @@ export const ws = reactive({
importing: null as string | null,
// 图片预览与管理,独立于笔记标签和草稿
image: null as string | null,
+ images: null as string | null,
// 电脑上侧栏折叠
sidebarCollapsed: layout.sidebarCollapsed ?? false,
// 大纲面板(电脑上在右侧常驻,手机上为浮层)
@@ -755,6 +756,7 @@ export async function move(from: string, to: string) {
remapOpenNotes(from, to)
remapRecent(from, to)
if (ws.image) ws.image = remapPath(ws.image, from, to) ?? ws.image
+ if (ws.images) ws.images = remapPath(ws.images, from, to) ?? ws.images
await api.move(from, to)
} catch (err) {
if (remapped) {
@@ -762,6 +764,7 @@ export async function move(from: string, to: string) {
remapOpenNotes(to, from)
remapRecent(to, from)
if (ws.image) ws.image = remapPath(ws.image, to, from) ?? ws.image
+ if (ws.images) ws.images = remapPath(ws.images, to, from) ?? ws.images
}
if (draftsMoved) {
try { await drafts.move(to, from) }
@@ -865,6 +868,7 @@ async function handleEvents(evs: VaultEvent[]) {
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') {
if (ws.image) ws.image = remapPath(ws.image, ev.from!, ev.path!) ?? ws.image
+ if (ws.images) ws.images = remapPath(ws.images, ev.from!, ev.path!) ?? ws.images
const paused = affected(ev.from!).map(slot)
paused.forEach((s) => s.paused++)
try {
@@ -1061,6 +1065,7 @@ function registerBuiltinCommands() {
{ id: 'view.outline', title: '显示/隐藏大纲', keys: ['Mod+Shift+O'], when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) },
{ id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') },
{ id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.sharing = ws.active) },
+ { id: 'app.images', title: '管理根目录图片', run: () => (ws.images = '') },
{ id: 'app.shares', title: '管理分享链接', run: () => (ws.settings = 'shares') },
{ id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') },
]) {
@@ -1096,6 +1101,7 @@ export function openSearch() {
export function stopWorkspace() {
ws.image = null
+ ws.images = null
stopPlugins()
disconnect?.()
disconnect = undefined