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
@@ -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; }