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
@@ -47,3 +47,16 @@ it('冲突文档禁用命令,保持原始内容', async () => {
expect(await executeEditorCommand('editor.import-note-properties')).toMatchObject({ ok: false, reason: 'unavailable' })
expect(store.content).toBe(original)
})
it('选择图片后写入工作区并插入相对 Markdown 引用', async () => {
vi.spyOn(workspace, 'storeWorkspaceImage').mockResolvedValue({
asset_id: 'asset-fixture', path: 'attachments/aa/hash.png', content_hash: 'hash',
media_type: 'image/png', size: 12, original_name: '截图.png', reference: 'attachments/aa/hash.png',
})
const input = wrapper!.get('input[type="file"]')
const file = new File(['png'], '截图.png', { type: 'image/png' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change')
await vi.waitFor(() => expect(useEditorStore().content).toContain('![截图.png](attachments/aa/hash.png)'))
expect(workspace.storeWorkspaceImage).toHaveBeenCalledWith(file, 'upload', '/fixture.md', 'note-fixture')
})
@@ -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>
@@ -17,6 +17,7 @@ import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand } from '@/services/editorCommandService'
import { headingFoldKey } from './headingFolding'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import * as workspace from '@/services/workspaceService'
type EditorComponent = { getEditor: () => Editor | undefined }
@@ -52,9 +53,26 @@ beforeEach(() => {
afterEach(() => {
mounted.splice(0).forEach((wrapper) => wrapper.unmount())
document.body.innerHTML = ''
vi.restoreAllMocks()
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it('uploads a selected image and keeps a portable Markdown reference', async () => {
const store = useEditorStore(); store.currentFilePath = '/课程/笔记.md'; store.currentNoteId = 'note-image'
vi.spyOn(workspace, 'storeWorkspaceImage').mockResolvedValue({
asset_id: 'asset-image', path: 'attachments/aa/hash.png', content_hash: 'hash',
media_type: 'image/png', size: 12, original_name: '图.png', reference: '../attachments/aa/hash.png',
})
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '' }, attachTo: document.body })
mounted.push(wrapper); const editor = await waitForEditor(wrapper)
const input = wrapper.get('input[type="file"]')
const file = new File(['png'], '图.png', { type: 'image/png' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change')
await vi.waitFor(() => expect(editor.action(getMarkdown())).toContain('![图.png](../attachments/aa/hash.png)'))
expect(workspace.storeWorkspaceImage).toHaveBeenCalledWith(file, 'upload', '/课程/笔记.md', 'note-image')
})
it('opens a rendered Markdown link on Ctrl click without changing its source', async () => {
const wrapper = mount(VisualMarkdownEditor, {
props: { initialContent: '[**文档**](https://example.com/docs)' }, attachTo: document.body,
@@ -50,6 +50,7 @@ import { headingFoldingPlugin, headingFoldTransaction, headingFoldKey, headingSe
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { t } from '@/i18n'
import { loadWorkspaceImage, resolveWorkspaceAssetPath, storeWorkspaceImage, type WorkspaceAssetSource } from '@/services/workspaceService'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
@@ -80,23 +81,92 @@ const loading = ref(true)
const allHeadingsFolded = ref(false)
const hasFoldableHeadings = ref(false)
const fontSizeInput = ref(16)
const imageInput = ref<HTMLInputElement | null>(null)
const imageError = ref('')
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
let disposeCodeLabels: (() => void) | undefined
let disposeLinkNavigation: (() => void) | undefined
let disposeCommands: (() => void) | undefined
let disposed = false
const imageUrls = new Set<string>()
function insertMarkdown(source: string) {
function insertMarkdown(source: string, position?: number) {
crepe?.editor.action(ctx => {
const doc = ctx.get(parserCtx)(source)
if (!doc) throw new Error('Invalid Markdown')
const view = ctx.get(editorViewCtx)
if (position !== undefined) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, Math.min(position, view.state.doc.content.size))))
view.dispatch(view.state.tr.replaceSelection(new Slice(doc.content, 0, 0)).scrollIntoView())
view.focus()
})
}
function imageFiles(list: FileList | null): File[] {
return [...(list ?? [])].filter(file => file.type.startsWith('image/'))
}
async function insertImages(files: File[], source: WorkspaceAssetSource, position?: number) {
if (!crepe || !editorStore.currentFilePath || !files.length) return
const target = crepe, targetPath = editorStore.currentFilePath
const document = target.editor.action(ctx => ctx.get(editorViewCtx).state.doc)
imageError.value = ''
try {
const assets = []
for (const file of files) assets.push(await storeWorkspaceImage(file, source, targetPath, editorStore.currentNoteId))
if (crepe !== target || editorStore.currentFilePath !== targetPath) return
const current = target.editor.action(ctx => ctx.get(editorViewCtx).state.doc)
insertMarkdown(assets.map(asset => `![${asset.original_name.replace(/[\]\\]/g, '\\$&')}](${asset.reference})`).join('\n\n'), current.eq(document) ? position : undefined)
} catch (reason) {
imageError.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 workspaceImageNodeView(node: { type: unknown; attrs: Record<string, unknown> }) {
const notePath = editorStore.currentFilePath
const dom = document.createElement('img')
let source = '', objectUrl = '', generation = 0
const apply = (next: typeof node) => {
const nextSource = String(next.attrs.src ?? '')
dom.alt = String(next.attrs.alt ?? '')
if (next.attrs.title) dom.title = String(next.attrs.title)
else dom.removeAttribute('title')
if (nextSource === source) return
source = nextSource
const current = ++generation
if (objectUrl) { URL.revokeObjectURL(objectUrl); imageUrls.delete(objectUrl); objectUrl = '' }
const path = notePath && resolveWorkspaceAssetPath(notePath, nextSource)
if (!path) { dom.src = nextSource; return }
dom.dataset.workspaceAsset = path
void loadWorkspaceImage(path, notePath, editorStore.currentNoteId).then(blob => {
const url = URL.createObjectURL(blob)
if (disposed || current !== generation) { URL.revokeObjectURL(url); return }
objectUrl = url; imageUrls.add(url); dom.src = url
}).catch(() => {
if (current === generation) dom.dataset.workspaceAssetError = 'true'
})
}
apply(node)
return {
dom,
update(next: typeof node) {
if (next.type !== node.type) return false
node = next; apply(next); return true
},
destroy() {
generation++
if (objectUrl) { URL.revokeObjectURL(objectUrl); imageUrls.delete(objectUrl) }
}
}
}
function insertCallout(event: Event) {
const select = event.target as HTMLSelectElement
if (select.value) insertMarkdown(`> [!${select.value.toUpperCase()}]\n> ${t('提示内容', 'Callout content')}`)
@@ -293,7 +363,12 @@ onMounted(async () => {
crepe = new Crepe({
root: editorRoot.value,
defaultValue: metadata.value?.body ?? props.initialContent,
features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math },
// 使用标准 Markdown 图片节点,确保 alt 文本和相对路径可被其他编辑器直接读取。
features: {
[Crepe.Feature.TopBar]: false,
[Crepe.Feature.Latex]: markdownPreferences.math,
[Crepe.Feature.ImageBlock]: false,
},
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: {
@@ -385,6 +460,23 @@ onMounted(async () => {
crepe.editor.use(inlineCodeInputPlugin)
if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin)
crepe.editor.use(headingFoldingPlugin)
crepe.editor.use($prose(() => new Plugin({
props: {
nodeViews: { image: workspaceImageNodeView },
handlePaste(_view, event) {
const files = imageFiles(event.clipboardData?.files ?? null)
if (!files.length) return false
event.preventDefault(); void insertImages(files, 'paste'); return true
},
handleDrop(view, event) {
const files = imageFiles(event.dataTransfer?.files ?? null)
if (!files.length) return false
event.preventDefault()
const position = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos ?? view.state.selection.from
void insertImages(files, 'drop', position); return true
},
},
})))
crepe.editor.use($prose(() => new Plugin({
view(view) {
const sync = (current: typeof view) => {
@@ -438,7 +530,7 @@ watch(() => editorStore.headingRequest, request => {
})
})
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeLinkNavigation?.(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); imageUrls.forEach(URL.revokeObjectURL); imageUrls.clear(); disposeLinkNavigation?.(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
@@ -446,6 +538,7 @@ defineExpose({ getEditor: () => crepe?.editor })
<template>
<DiagramInteractions class="visual-editor" :class="{ 'hide-code-line-numbers': !markdownPreferences.lineNumbers }" :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<p v-if="imageError" class="image-error" role="alert">{{ imageError }}</p>
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<div class="section-actions">
<button type="button" :disabled="loading || !hasFoldableHeadings"
@@ -489,6 +582,8 @@ defineExpose({ getEditor: () => crepe?.editor })
<button v-if="markdownPreferences.math" type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
<button v-if="markdownPreferences.math" type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph"></span></button>
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
<button type="button" :title="t('插入工作区图片', 'Insert workspace image')" :aria-label="t('插入工作区图片', 'Insert workspace image')" @pointerdown.prevent="chooseImages"><span class="image-glyph"></span></button>
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple @change="selectedImages" />
<label class="toolbar-select">
<select v-if="markdownPreferences.callouts" :aria-label="t('插入警告框', 'Insert callout')" @change="insertCallout">
<option value="">{{ t('提示框', 'Callout') }}</option>
@@ -514,6 +609,7 @@ defineExpose({ getEditor: () => crepe?.editor })
<style scoped>
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
.image-error { margin: 0; padding: var(--space-sm) var(--space-lg); color: var(--color-error); background: var(--color-error-soft); }
.hide-code-line-numbers :deep(.cm-lineNumbers) { display: none; }
.markdown-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 42px; padding: 5px var(--space-lg); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-primary); }
.markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
@@ -532,6 +628,8 @@ defineExpose({ getEditor: () => crepe?.editor })
.list-lines { overflow: hidden; width: 14px; font-size: 15px; line-height: 1; transform: scaleX(1.2); }
.code-glyph, .block-glyph { padding: 0; background: transparent; color: inherit; font: 700 13px/1 var(--font-editor-mono); }
.math-glyph { font: italic 700 16px/1 Georgia, 'Times New Roman', serif; }
.image-glyph { font-size: 18px; line-height: 1; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.toolbar-select { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 5px 3px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
.toolbar-select select { min-width: 58px; border: 0; outline: 0; background: transparent; color: inherit; cursor: pointer; font-size: var(--font-size-sm); }
.font-size-select select { min-width: 62px; }
+2 -2
View File
@@ -158,8 +158,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
}
export const apiClient = {
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }) {
return request<T>(path, { method: 'POST', body, headers })
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }, params?: RequestOptions['params']) {
return request<T>(path, { method: 'POST', body, headers, params })
},
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'GET' })
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { resolveWorkspaceAssetPath, workspaceAssetReference } from './workspaceService'
describe('workspace asset paths', () => {
it('creates portable references relative to the note', () => {
expect(workspaceAssetReference('/课程/系统/调度.md', 'attachments/ab/hash.png')).toBe('../../attachments/ab/hash.png')
expect(resolveWorkspaceAssetPath('/课程/系统/调度.md', '../../attachments/ab/hash.png')).toBe('attachments/ab/hash.png')
})
it('does not resolve remote URLs or paths escaping the vault', () => {
expect(resolveWorkspaceAssetPath('/a.md', 'https://example.com/image.png')).toBeNull()
expect(resolveWorkspaceAssetPath('/a.md', '../attachments/ab/hash.png')).toBeNull()
})
})
+46
View File
@@ -12,6 +12,17 @@ import * as noteService from './noteService'
import { splitNoteMetadata } from '@/utils/noteMetadata'
import { contentHash, hostInvoke, isDesktop, nativePath, nativeTree, type HostDocument, type HostEntry, type HostVault } from './platform/desktop'
export interface WorkspaceAsset {
asset_id: string
path: string
content_hash: string
media_type: string
size: number
original_name: string
}
export type WorkspaceAssetSource = 'paste' | 'drop' | 'upload'
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
export interface VaultInfo {
vault_id: string
@@ -147,6 +158,41 @@ export async function readFileContent(filePath: string): Promise<string> {
return note.markdown
}
/** 将 Vault 根路径转换为相对当前笔记的可移植 Markdown 引用。 */
export function workspaceAssetReference(notePath: string, assetPath: string): string {
const noteParts = relativePath(notePath).split('/').filter(Boolean)
noteParts.pop()
return `${'../'.repeat(noteParts.length)}${relativePath(assetPath)}`
}
/** 将笔记内的相对附件引用还原为 Vault 根路径。 */
export function resolveWorkspaceAssetPath(notePath: string, reference: string): string | null {
if (/^(?:[a-z]+:|\/\/|#)/i.test(reference)) return null
const parts = [...relativePath(notePath).split('/').slice(0, -1)]
for (const part of reference.replace(/\\/g, '/').split('/')) {
if (!part || part === '.') continue
if (part === '..') { if (!parts.length) return null; parts.pop() }
else parts.push(part)
}
const path = parts.join('/')
return path.startsWith('attachments/') ? path : null
}
export async function storeWorkspaceImage(
file: Blob & { name?: string }, source: WorkspaceAssetSource,
notePath: string, noteId: string | null,
): Promise<WorkspaceAsset & { reference: string }> {
const asset = await apiClient.postBinary<WorkspaceAsset>('/api/workspace/assets', file, { 'Content-Type': 'application/octet-stream' }, {
filename: file.name || 'image', note_id: noteId || '', note_path: notePath, source,
})
return { ...asset, reference: workspaceAssetReference(notePath, asset.path) }
}
export async function loadWorkspaceImage(path: string, notePath = '', noteId: string | null = null): Promise<Blob> {
const response = await apiClient.get<Response>('/api/workspace/assets/content', { params: { path, note_path: notePath, note_id: noteId || '' } })
return response.blob()
}
/** 解析已与工作空间路径关联的后端笔记标识。 */
export async function getNoteId(filePath: string): Promise<string> {
return requireNoteId(filePath)