feat(workspace): 完成图片资产存储与引用

This commit is contained in:
2026-09-13 21:52:14 +08:00
parent c87b56a13f
commit 9c35f54560
16 changed files with 655 additions and 10 deletions
@@ -9,10 +9,12 @@ import { useSettingsStore } from '@/stores/settings'
import { registerEditorCommands } from '@/services/editorCommandService'
import { previewPropertyImport, type PropertyChoices, type PropertyConflict } from './importProperties'
import AppDialog from '@/components/common/AppDialog.vue'
import { storeWorkspaceImage, type WorkspaceAssetSource } from '@/services/workspaceService'
const props = defineProps<{ initialContent: string }>()
const editor = useEditorStore(), settings = useSettingsStore()
const root = ref<HTMLElement | null>(null), error = ref('')
const imageInput = ref<HTMLInputElement | null>(null)
const conflicts = ref<PropertyConflict[]>([]), choices = ref<PropertyChoices>({})
const proofing = new Compartment()
let view: EditorView | undefined, dispose: (() => void) | undefined
@@ -22,6 +24,31 @@ function attributes() {
'aria-label': settings.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器' })
}
function available() { return !!view && !!editor.currentFilePath && !['conflict', 'external_changed'].includes(editor.saveStatus) }
function imageFiles(list: FileList | null): File[] {
return [...(list ?? [])].filter(file => file.type.startsWith('image/'))
}
async function insertImages(files: File[], source: WorkspaceAssetSource, position?: number) {
if (!view || !available() || !files.length) return
const targetView = view, targetPath = editor.currentFilePath
const at = position ?? targetView.state.selection.main.from
const document = targetView.state.doc
error.value = ''
try {
const assets = []
for (const file of files) assets.push(await storeWorkspaceImage(file, source, targetPath!, editor.currentNoteId))
if (view !== targetView || editor.currentFilePath !== targetPath || !available()) return
const markdown = assets.map(asset => `![${asset.original_name.replace(/[\]\\]/g, '\\$&')}](${asset.reference})`).join('\n\n')
const insertion = targetView.state.doc.eq(document) ? Math.min(at, targetView.state.doc.length) : targetView.state.selection.main.from
targetView.dispatch({ changes: { from: insertion, insert: markdown } })
targetView.focus()
} catch (reason) { error.value = reason instanceof Error ? reason.message : String(reason) }
}
function chooseImages() { imageInput.value?.click() }
function selectedImages(event: Event) {
const input = event.target as HTMLInputElement
void insertImages(imageFiles(input.files), 'upload')
input.value = ''
}
function importProperties() {
if (!available() || !view) return { ok: false as const, reason: 'unavailable' as const }
error.value = ''; choices.value = {}
@@ -57,6 +84,20 @@ onMounted(() => {
view = new EditorView({ parent: root.value!, state: EditorState.create({ doc: props.initialContent, extensions: [
history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), markdown(), proofing.of(attributes()),
EditorView.lineWrapping,
EditorView.domEventHandlers({
paste(event) {
const files = imageFiles(event.clipboardData?.files ?? null)
if (!files.length) return false
event.preventDefault(); void insertImages(files, 'paste'); return true
},
drop(event, currentView) {
const files = imageFiles(event.dataTransfer?.files ?? null)
if (!files.length) return false
event.preventDefault()
const position = currentView.posAtCoords({ x: event.clientX, y: event.clientY }) ?? currentView.state.selection.main.from
void insertImages(files, 'drop', position); return true
},
}),
EditorView.updateListener.of(update => {
if (update.docChanged) { editor.updateContent(update.state.doc.toString()); editor.scheduleAutoSave(settings.autoSaveInterval) }
}),
@@ -82,7 +123,11 @@ onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })
<template>
<div class="source-container">
<div class="source-actions"><button class="btn" :disabled="!editor.currentFilePath || ['conflict', 'external_changed'].includes(editor.saveStatus)" @click="importProperties">导入为笔记属性</button></div>
<div class="source-actions">
<button class="btn" :disabled="!editor.currentFilePath || ['conflict', 'external_changed'].includes(editor.saveStatus)" @click="importProperties">导入为笔记属性</button>
<button class="btn" :disabled="!available()" @click="chooseImages">插入图片</button>
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple @change="selectedImages" />
</div>
<p v-if="error" role="alert">{{ error }}</p>
<div ref="root" class="source-code" />
<AppDialog v-if="conflicts.length" label="属性冲突预览" @close="conflicts = []; pending = undefined">
@@ -100,6 +145,7 @@ onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })
<style scoped>
.source-container { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.source-code { flex: 1; min-height: 0; overflow: hidden; }
.source-actions { padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
.source-actions { display: flex; gap: var(--space-sm); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>