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/ImageGallery.vue b/web/src/app/ImageGallery.vue new file mode 100644 index 0000000..5a3e6fd --- /dev/null +++ b/web/src/app/ImageGallery.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/web/src/app/Sidebar.vue b/web/src/app/Sidebar.vue index 2298176..621dc35 100644 --- a/web/src/app/Sidebar.vue +++ b/web/src/app/Sidebar.vue @@ -11,6 +11,12 @@ import PluginMount from './PluginMount.vue' const emit = defineEmits<{ logout: [] }>() const rootDrop = ref(false) +const rootMenu = ref<{ x: number; y: number } | null>(null) +function rootContext(event: MouseEvent) { + if (event.target instanceof Element && event.target.closest('.rm-tree-row')) return + event.preventDefault() + rootMenu.value = { x: Math.max(4, Math.min(event.clientX, innerWidth - 170)), y: Math.max(4, Math.min(event.clientY, innerHeight - 50)) } +} const selectedView = ref('') const currentView = computed(() => sidebarViews.get(selectedView.value)) watch(() => ws.searchOpen, () => { selectedView.value = '' }) @@ -47,12 +53,16 @@ function onRootDrop(e: DragEvent) { -
+

还没有笔记

+
diff --git a/web/src/app/TreeNode.vue b/web/src/app/TreeNode.vue index 4c3aea2..9e26d54 100644 --- a/web/src/app/TreeNode.vue +++ b/web/src/app/TreeNode.vue @@ -73,13 +73,14 @@ function commitRename() { if (target && target !== props.node.path) void move(props.node.path, target) } -function runMenu(action: 'note' | 'folder' | 'import' | 'rename' | 'move' | 'share' | 'delete') { +function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'share' | 'delete') { menuOpen.value = false const path = props.node.path if (action === 'note' || action === 'folder') { expanded.add(path) void (action === 'note' ? createNote(path) : createFolder(path)) } else if (action === 'import') ws.importing = path + else if (action === 'images') ws.images = path else if (action === 'rename') ws.renaming = path else if (action === 'share') ws.sharing = path else if (action === 'move') ws.moving = path @@ -152,6 +153,7 @@ function onDrop(e: DragEvent) { diff --git a/web/src/app/Workspace.vue b/web/src/app/Workspace.vue index db27f33..11be41d 100644 --- a/web/src/app/Workspace.vue +++ b/web/src/app/Workspace.vue @@ -7,6 +7,7 @@ import Icon from './Icon.vue' import MobileToolbar from './MobileToolbar.vue' import MoveDialog from './MoveDialog.vue' import ImportDialog from './ImportDialog.vue' +import ImageGallery from './ImageGallery.vue' import ImageDialog from './ImageDialog.vue' import NoteBanner from './NoteBanner.vue' import OutlinePanel from './OutlinePanel.vue' @@ -123,6 +124,7 @@ onBeforeUnmount(() => { +
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