feat(workspace): 完成图片资产存储与引用
This commit is contained in:
@@ -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(''))
|
||||
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(''))
|
||||
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; }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user