Add image listing and asset management

Show images in asset directories with preview, rename, replacement, and trash recovery. Update local Markdown references when images move and restore original files if related metadata cannot be committed.

Refresh image previews after replacement and compare actual contents when deduplicating subsequent uploads.
This commit is contained in:
eezd committed 2026-10-21 15:14:07 +08:00
1 parent 8b54ccba18
commit d253536009
25 files changed
+801 -103

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ const paths = {
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',
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ApiError } from './api'
import Icon from './Icon.vue'
import { basename, dirname, isImage, joinPath } from './paths'
import { imageUrl, move, remove, replaceImage, ws } from './workspace'
const props = defineProps<{ path: string }>()
const dialog = ref<HTMLElement>()
const picker = ref<HTMLInputElement>()
const busy = ref(false)
const error = ref('')
const loadError = ref(false)
const dimensions = ref('')
const name = ref('')
const path = computed(() => props.path)
const entry = computed(() => ws.entries.find((e) => e.path === path.value))
const extension = computed(() => basename(path.value).slice(basename(path.value).lastIndexOf('.')))
const src = computed(() => imageUrl(path.value))
const siblings = computed(() => ws.entries.filter((e) => !e.dir && isImage(e.path) && dirname(e.path) === dirname(path.value)))
const index = computed(() => siblings.value.findIndex((e) => e.path === path.value))
const size = computed(() => entry.value ? (entry.value.size / 1024).toLocaleString('zh-CN', { maximumFractionDigits: 1 }) + ' KB' : '')
watch(path, (p) => { name.value = basename(p).slice(0, -extension.value.length); error.value = '' }, { immediate: true })
watch(src, () => { loadError.value = false; dimensions.value = '' })
onMounted(() => dialog.value?.focus())
function close() {
if (!busy.value) ws.image = null
}
function select(offset: number) {
const target = siblings.value[index.value + offset]
if (target && !busy.value) ws.image = target.path
}
function loaded(event: Event) {
const image = event.target as HTMLImageElement
dimensions.value = `${image.naturalWidth} × ${image.naturalHeight}`
}
async function rename() {
const base = name.value.trim()
if (!base || base.startsWith('.') || /[/\\]/.test(base)) { error.value = '图片名称不能为空、以点开头或包含斜杠'; return }
const to = joinPath(dirname(path.value), base + extension.value)
if (to === path.value || busy.value) return
busy.value = true
error.value = ''
try { await move(path.value, to) }
catch (err) { error.value = err instanceof Error ? err.message : '修改后同步失败,请检查目录' }
finally { busy.value = false }
}
async function replace(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || busy.value) return
if (file.size > 50 * 1024 * 1024) { error.value = '图片不能超过 50 MB'; return }
if (!file.name.toLowerCase().endsWith(extension.value.toLowerCase())) { error.value = '请选择与原图片扩展名相同的文件'; return }
if (!confirm('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
busy.value = true
error.value = ''
try { await replaceImage(path.value, file) }
catch (err) { error.value = err instanceof ApiError ? err.message : '网络错误,替换结果尚未确认,请检查图片后再重试' }
finally { busy.value = false }
}
async function trash() {
if (busy.value || !confirm('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
busy.value = true
try { await remove(path.value) }
finally { busy.value = false }
}
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close">
<section ref="dialog" class="rm-dialog rm-image-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="图片管理" :aria-busy="busy">
<div class="rm-dialog-head">
<h2>图片管理</h2>
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close"><Icon name="close" /></button>
</div>
<div class="rm-image-body">
<p class="rm-image-path">{{ path }}</p>
<div class="rm-image-preview">
<p v-if="!entry">图片已不存在,请刷新目录</p>
<p v-else-if="loadError" role="alert">图片无法预览,文件可能损坏或浏览器不支持此格式。</p>
<img v-else :key="src" :src="src" :alt="basename(path)" @load="loaded" @error="loadError = true" />
</div>
<div class="rm-image-navigation">
<button class="rm-btn" :disabled="busy || index <= 0" @click="select(-1)">上一张</button>
<span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
<button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">下一张</button>
</div>
<form class="rm-image-rename" @submit.prevent="rename">
<label for="rm-image-name">图片名称</label>
<div>
<input id="rm-image-name" v-model="name" :disabled="busy || !entry" required />
<span>{{ extension }}</span>
<button class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">重命名</button>
</div>
</form>
<p class="rm-image-hint">改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。</p>
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
<div class="rm-image-actions">
<input ref="picker" type="file" :accept="extension" hidden :disabled="busy" @change="replace" />
<button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">移入回收站</button>
<button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">{{ busy ? '处理中…' : '替换图片' }}</button>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.rm-image-dialog { max-width: 860px; width: min(860px, calc(100vw - 24px)); }
.rm-image-body { display: flex; flex-direction: column; gap: 14px; padding: 0 20px 20px; overflow-y: auto; font-size: 14px; }
.rm-image-body p { margin: 0; }
.rm-image-path { overflow-wrap: anywhere; color: var(--rm-text-muted); }
.rm-image-preview { display: flex; align-items: center; justify-content: center; min-height: 140px; background: var(--rm-bg); border: 1px solid var(--rm-divider); border-radius: 6px; padding: 12px; }
.rm-image-preview img { display: block; max-width: 100%; max-height: 44dvh; object-fit: contain; }
.rm-image-navigation, .rm-image-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.rm-image-navigation span { font-size: 12px; color: var(--rm-text-muted); text-align: center; }
.rm-image-navigation button, .rm-image-rename button { white-space: nowrap; }
.rm-image-rename { display: flex; flex-direction: column; gap: 8px; }
.rm-image-rename > div { display: flex; align-items: center; gap: 8px; }
.rm-image-rename input { min-width: 0; flex: 1; width: 0; border: 1px solid var(--rm-divider); background: var(--rm-bg); color: var(--rm-text); padding: 8px; border-radius: 6px; font: inherit; }
.rm-image-hint { color: var(--rm-text-muted); font-size: 12px; line-height: 1.6; }
</style>
+2 -2
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { fuzzyMatch, segments } from './fuzzy'
import Icon from './Icon.vue'
import { basename, dirname, joinPath, noteTitle } from './paths'
import { basename, dirname, inAssetDir, joinPath, noteTitle } from './paths'
import { move, ws } from './workspace'
// 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快)
@@ -20,7 +20,7 @@ const title = computed(() => (isDir.value ? basename(props.path) : noteTitle(pro
const targets = computed(() => {
const here = dirname(props.path)
const inside = (d: string) => isDir.value && (d === props.path || d.startsWith(props.path + '/'))
const dirs = ws.entries.filter((e) => e.dir && e.path !== here && !inside(e.path)).map((e) => e.path)
const dirs = ws.entries.filter((e) => e.dir && !inAssetDir(e.path) && e.path !== here && !inside(e.path)).map((e) => e.path)
return here === '' ? dirs : ['', ...dirs]
})
+1 -1
View File
@@ -41,7 +41,7 @@ function cancel() {
function noteItems(q: string): Item[] {
const recentRank = new Map(ws.recent.map((p, i) => [p, i]))
const notes = ws.entries.filter((e) => !e.dir)
const notes = ws.entries.filter((e) => !e.dir && e.path.endsWith(NOTE_EXT))
const toItem = (path: string, positions: number[] = []): Item => {
const dir = dirname(path)
const offset = dir ? dir.length + 1 : 0
+2 -1
View File
@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue'
import { api, type TrashItem } from './api'
import Icon from './Icon.vue'
import { isImage } from './paths'
import { restore, toast } from './workspace'
const items = ref<TrashItem[] | null>(null)
@@ -41,7 +42,7 @@ onMounted(load)
<p v-else-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
<ul v-else class="rm-trash-list">
<li v-for="item in items ?? []" :key="item.id">
<Icon :name="item.dir ? 'folder' : 'note'" />
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" />
<div class="rm-trash-info">
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
+17 -12
View File
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { basename, joinPath } from './paths'
import { basename, inAssetDir, isImage, joinPath } from './paths'
import { expanded, type TreeNode } from './tree'
import { createFolder, createNote, move, openNote, remove, renameTarget, ws } from './workspace'
const props = defineProps<{ node: TreeNode; depth: number }>()
const image = computed(() => !props.node.dir && isImage(props.node.path))
const assetDir = computed(() => props.node.dir && inAssetDir(props.node.path))
const isOpen = computed(() => expanded.has(props.node.path))
const isActive = computed(() => ws.active === props.node.path)
const isActive = computed(() => (image.value ? ws.image : ws.active) === props.node.path)
const isRenaming = computed(() => ws.renaming === props.node.path)
const menuOpen = ref(false)
const dropTarget = ref(false)
@@ -48,7 +50,8 @@ function activate() {
if (props.node.dir) {
if (isOpen.value) expanded.delete(props.node.path)
else expanded.add(props.node.path)
} else void openNote(props.node.path)
} else if (image.value) ws.image = props.node.path
else void openNote(props.node.path)
}
watch(
@@ -94,7 +97,7 @@ function onDragStart(e: DragEvent) {
}
function onDragOver(e: DragEvent) {
if (!props.node.dir || !e.dataTransfer?.types.includes('application/x-rawmark-path')) return
if (!props.node.dir || assetDir.value || !e.dataTransfer?.types.includes('application/x-rawmark-path')) return
e.preventDefault()
e.stopPropagation()
dropTarget.value = true
@@ -103,7 +106,7 @@ function onDragOver(e: DragEvent) {
function onDrop(e: DragEvent) {
dropTarget.value = false
const src = e.dataTransfer?.getData('application/x-rawmark-path')
if (!props.node.dir || !src) return
if (!props.node.dir || assetDir.value || !src) return
e.preventDefault()
e.stopPropagation()
const to = joinPath(props.node.path, basename(src))
@@ -117,7 +120,7 @@ function onDrop(e: DragEvent) {
class="rm-tree-row"
:class="{ active: isActive, drop: dropTarget, 'menu-open': menuOpen }"
:style="{ paddingLeft: depth * 14 + 8 + 'px' }"
:draggable="!isRenaming"
:draggable="!isRenaming && !assetDir"
@click="activate"
@contextmenu.prevent="menuOpen = true"
@touchstart.passive="onTouchStart"
@@ -140,20 +143,22 @@ function onDrop(e: DragEvent) {
@keydown.esc.prevent="cancelRename"
@blur="commitRename"
/>
<span v-else class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
<Icon v-if="image" name="image" :size="14" />
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
<button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen = !menuOpen">
<Icon name="more" />
</button>
<div v-if="menuOpen" class="rm-menu" @click.stop>
<template v-if="node.dir">
<template v-if="node.dir && !assetDir">
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
<button @click="runMenu('import')"><Icon name="import" />导入</button>
</template>
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button @click="runMenu('share')"><Icon name="link" />分享</button>
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
<button v-if="image" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
<button v-if="!assetDir" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button v-if="!image" @click="runMenu('share')"><Icon name="link" />分享</button>
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div>
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
</div>
+2
View File
@@ -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 ImageDialog from './ImageDialog.vue'
import NoteBanner from './NoteBanner.vue'
import OutlinePanel from './OutlinePanel.vue'
import QuickSwitcher from './QuickSwitcher.vue'
@@ -122,6 +123,7 @@ onBeforeUnmount(() => {
<SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
<ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" />
<ImageDialog v-if="ws.image" :path="ws.image" />
<MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" />
</div>
</template>
+6
View File
@@ -174,6 +174,12 @@ export const api = {
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
},
replaceImage: (path: string, file: File) => {
const body = new FormData()
body.append('file', file)
return request('PUT', '/api/v1/assets/' + encodePath(path), { body })
},
importImage: async (note: string, url: string): Promise<string> =>
(await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link,
+8
View File
@@ -2,6 +2,14 @@ import { encodePath } from './api'
export const NOTE_EXT = '.md'
export function isImage(path: string): boolean {
return /\.(png|jpe?g|gif|webp|svg|avif|apng|bmp|ico|tiff?)$/i.test(path)
}
export function inAssetDir(path: string): boolean {
return path.split('/').some((part) => part.endsWith('.assets'))
}
export function dirname(path: string): string {
const i = path.lastIndexOf('/')
return i < 0 ? '' : path.slice(0, i)
+1 -1
View File
@@ -37,7 +37,7 @@ watch(expanded, () => localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expa
// 打开或新建笔记时展开其所在的各级目录
watch(
() => [ws.active, ws.renaming],
() => [ws.active, ws.renaming, ws.image],
(paths) => {
for (const p of paths) {
for (let d = p ? dirname(p) : ''; d; d = dirname(d)) expanded.add(d)
+32 -1
View File
@@ -6,7 +6,7 @@ import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts'
import { connectEvents } from './events'
import { pluginEvents } from '../plugins/registry'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, importNotes, move, readyToReload, openNote, refreshTree, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, importNotes, move, readyToReload, openNote, refreshTree, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
@@ -275,3 +275,34 @@ describe('导入', () => {
expect(readyToReload()).toBe(true)
})
})
describe('图片管理', () => {
it('替换期间阻止刷新,完成后更新列表;刷新失败不伪装成替换失败', async () => {
ws.tabs = []
let finish!: (value: Response) => void
vi.spyOn(api, 'replaceImage').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
const replacing = replaceImage('assets/a.png', new File(['image'], 'a.png'))
expect(readyToReload()).toBe(false)
finish(new Response())
await replacing
expect(ws.entries.find((e) => e.path === 'assets/a.png')?.mtime).toBe(2)
expect(readyToReload()).toBe(true)
vi.mocked(api.replaceImage).mockResolvedValueOnce(new Response())
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '读取失败'))
await replaceImage('assets/a.png', new File(['image'], 'a.png'))
expect(ws.toasts.some((t) => t.text.includes('图片已替换,但刷新目录失败'))).toBe(true)
})
it('恢复图片只打开预览,不创建 Markdown 标签', async () => {
ws.tabs = []
ws.active = null
vi.spyOn(api, 'restore').mockResolvedValue({ id: 'trash-image', path: 'assets/a.png', dir: false, deletedAt: 1 })
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
await restore('trash-image')
expect(ws.image).toBe('assets/a.png')
expect(ws.tabs).toEqual([])
expect(ws.active).toBeNull()
})
})
+46 -9
View File
@@ -3,12 +3,13 @@
import { Annotation, EditorState, Transaction } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { nextTick, reactive, watch } from 'vue'
import { refreshImages } from '../editor/livepreview'
import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment } from '../editor'
import { handleShortcut, registerCommand } from './commands'
import { api, ApiError, type Entry } from './api'
import { api, ApiError, encodePath, type Entry } from './api'
import { drafts } from './drafts'
import { connectEvents, type VaultEvent } from './events'
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
import { basename, dirname, joinPath, isImage, NOTE_EXT, resolveAssetUrl } from './paths'
import { loadSettings, settings } from './settings'
import { trackVisualViewport } from './viewport'
import { appearancePaused } from '../themes/appearance'
@@ -64,6 +65,8 @@ export const ws = reactive({
moving: null as string | null,
// 导入目标目录,空字符串为根目录
importing: null as string | null,
// 图片预览与管理,独立于笔记标签和草稿
image: null as string | null,
// 电脑上侧栏折叠
sidebarCollapsed: layout.sidebarCollapsed ?? false,
// 大纲面板(电脑上在右侧常驻,手机上为浮层)
@@ -180,7 +183,11 @@ function buildState(path: string, doc: string): EditorState {
doc,
spellcheck: settings.spellcheck,
tabSize: settings.tabSize,
resolveImage: (src) => resolveAssetUrl(ref.path, src),
resolveImage: (src) => {
const url = resolveAssetUrl(ref.path, src)
const entry = ws.entries.find((e) => url === '/api/v1/raw/' + encodePath(e.path))
return entry ? imageUrl(entry.path) : url
},
onChange: (u) => {
editorStates.set(ref.path, u.state)
if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path)
@@ -591,7 +598,11 @@ async function fetchTree() {
if (opsInFlight === 0) scheduleTreeRefresh()
return
}
const oldImages = new Map(ws.entries.filter((e) => !e.dir && isImage(e.path)).map((e) => [e.path, e]))
const nextPaths = new Set(entries.map((e) => e.path))
const imagesChanged = entries.some((e) => !e.dir && isImage(e.path) && (e.mtime !== oldImages.get(e.path)?.mtime || e.size !== oldImages.get(e.path)?.size)) || [...oldImages.keys()].some((p) => !nextPaths.has(p))
ws.entries = entries
if (imagesChanged) view?.dispatch({ effects: refreshImages.of(null) })
markMissingTabsDeleted()
for (const waiter of treeWaiters.splice(0)) waiter.resolve()
}
@@ -697,7 +708,7 @@ export function renameTarget(path: string, name: string): string | null {
name = name.trim().replace(/\//g, '')
if (!name || name.startsWith('.')) return null
const entry = ws.entries.find((e) => e.path === path)
if (entry && !entry.dir && !name.endsWith(NOTE_EXT)) name += NOTE_EXT
if (entry && !entry.dir && path.endsWith(NOTE_EXT) && !name.endsWith(NOTE_EXT)) name += NOTE_EXT
return joinPath(dirname(path), name)
}
@@ -709,19 +720,26 @@ export async function move(from: string, to: string) {
return
}
await withOp(async () => {
const busy = () => affected(from).some((p) => slots.get(p)?.uploads || slots.get(p)?.paused)
// 图片改名会更新仓库中的引用,先保存已打开笔记,沿用版本冲突保护。
const imageMove = isImage(from) && ws.entries.some((e) => e.path === from && !e.dir)
const impacted = () => imageMove ? ws.tabs.map((t) => t.path) : affected(from)
const busy = () => impacted().some((p) => slots.get(p)?.uploads || slots.get(p)?.paused)
if (busy()) {
toast('笔记正在处理粘贴、上传或移动,请完成后再移动')
return
}
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
await Promise.all(affected(from).map((p) => flush(p)))
await Promise.all(impacted().map((p) => flush(p)))
// 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。
if (busy()) {
toast('笔记正在处理粘贴、上传或移动,请完成后再移动')
return
}
const paused = affected(from).map(slot)
if (imageMove && ws.tabs.some((t) => t.dirty || t.status !== 'ready')) {
toast('请先保存笔记并解决冲突,再修改图片名称或位置')
return
}
const paused = impacted().map(slot)
paused.forEach((s) => s.paused++)
const snapshot = ws.entries
let draftsMoved = false
@@ -734,12 +752,14 @@ export async function move(from: string, to: string) {
ws.entries = ws.entries.map((e) => ({ ...e, path: remapPath(e.path, from, to) ?? e.path }))
remapOpenNotes(from, to)
remapRecent(from, to)
if (ws.image) ws.image = remapPath(ws.image, from, to) ?? ws.image
await api.move(from, to)
} catch (err) {
if (remapped) {
ws.entries = snapshot
remapOpenNotes(to, from)
remapRecent(to, from)
if (ws.image) ws.image = remapPath(ws.image, to, from) ?? ws.image
}
if (draftsMoved) {
try { await drafts.move(to, from) }
@@ -751,7 +771,7 @@ export async function move(from: string, to: string) {
paused.forEach((s) => s.paused--)
}
// 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步
await Promise.all(affected(to).map(async (p) => {
await Promise.all((imageMove ? ws.tabs.map((t) => t.path) : affected(to)).map(async (p) => {
const tab = tabOf(p)
if (!tab) return
if (tab.dirty) {
@@ -777,6 +797,7 @@ export async function remove(path: string) {
}
try {
const item = await api.deleteFile(path)
if (ws.image && remapPath(ws.image, path, path) !== null) ws.image = null
toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, {
label: '撤销',
run: () => void restore(item.id),
@@ -792,12 +813,25 @@ export async function restore(id: string) {
try {
const item = await api.restore(id)
await refreshTree()
if (!item.dir) void openNote(item.path)
if (!item.dir && item.path.endsWith(NOTE_EXT)) void openNote(item.path)
else if (!item.dir && isImage(item.path)) ws.image = item.path
} catch (err) {
toast('恢复失败:' + errorText(err))
}
}
// 版本只用于刷新浏览器图片,不改变 Markdown 中保存的相对路径。
export function imageUrl(path: string): string {
const entry = ws.entries.find((e) => e.path === path)
return '/api/v1/raw/' + encodePath(path) + (entry ? `?v=${entry.mtime}-${entry.size}` : '')
}
export async function replaceImage(path: string, file: File) {
await withOp(() => api.replaceImage(path, file))
try { await refreshTree() }
catch (err) { toast('图片已替换,但刷新目录失败:' + errorText(err)) }
}
// ---- 实时同步 ----
async function syncOpenTab(tab: Tab) {
@@ -828,6 +862,7 @@ async function handleEvents(evs: VaultEvent[]) {
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
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
const paused = affected(ev.from!).map(slot)
paused.forEach((s) => s.paused++)
try {
@@ -842,6 +877,7 @@ async function handleEvents(evs: VaultEvent[]) {
remapRecent(ev.from!, ev.path!)
} finally { paused.forEach((s) => s.paused--) }
} else if (ev.type === 'deleted') {
if (ws.image === ev.path && opsInFlight === 0) ws.image = null
const tab = tabOf(ev.path!)
if (tab && tab.status === 'ready' && opsInFlight === 0) {
tab.status = 'deleted'
@@ -1053,6 +1089,7 @@ export function openSearch() {
}
export function stopWorkspace() {
ws.image = null
stopPlugins()
disconnect?.()
disconnect = undefined
+5 -1
View File
@@ -1,5 +1,5 @@
import { syntaxTree } from '@codemirror/language'
import { Facet, type EditorState, type Range } from '@codemirror/state'
import { StateEffect, Facet, type EditorState, type Range } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { codeContentRange, fenceLanguage, fencedCodeAt } from './codeblocks'
@@ -21,6 +21,9 @@ export const resolveImageUrl = Facet.define<(src: string) => string, (src: strin
combine: (values) => values[0] ?? ((src) => src),
})
// 图片文件原位替换时重建预览,不改变正文、选区或撤销历史。
export const refreshImages = StateEffect.define<null>()
const hidden = Decoration.replace({})
const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
const linkText = Decoration.mark({ class: 'cm-rm-link' })
@@ -328,6 +331,7 @@ const livePreviewPlugin = ViewPlugin.fromClass(
}
if (
this.stale ||
u.transactions.some((tr) => tr.effects.some((effect) => effect.is(refreshImages))) ||
u.docChanged ||
u.viewportChanged ||
u.selectionSet ||