feat(预览): 支持图片文件预览和Markdown相对路径解析

- 新增图片类型标签页,用于预览本地图片文件
- 在useTabs中根据文件扩展名自动识别图片文件并设置tab类型
- 为Preview组件添加文件路径属性,用于解析Markdown中的相对图片路径
- 实现Markdown渲染器对相对路径的解析,将相对路径转换为file://协议URL
- 添加图片点击预览功能,点击Markdown中的图片可放大查看
This commit is contained in:
cfq 2026-01-26 14:54:00 +08:00
parent 6a8b3902a2
commit 3a9a325f80
5 changed files with 125 additions and 6 deletions

View File

@ -7,7 +7,7 @@
/> />
</div> </div>
<div class="preview-pane" v-show="mode !== 'source'" :style="{ width: mode === 'split' ? '50%' : '100%' }"> <div class="preview-pane" v-show="mode !== 'source'" :style="{ width: mode === 'split' ? '50%' : '100%' }">
<Preview :content="localContent" /> <Preview :content="localContent" :file-path="tab.filePath" />
</div> </div>
<!-- 模式切换浮动按钮 --> <!-- 模式切换浮动按钮 -->

View File

@ -0,0 +1,48 @@
<template>
<div class="image-tab">
<div v-if="fileUrl" class="image-container">
<a-image :src="fileUrl" :preview="true" />
</div>
<div v-else class="empty-state">
无法预览该图片
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
tab: {
type: Object,
required: true
}
});
const toFileUrl = (absolutePath) => {
if (!absolutePath) return '';
const normalized = absolutePath.replace(/\\/g, '/');
return encodeURI(`file:///${normalized}`);
};
const fileUrl = computed(() => toFileUrl(props.tab.filePath));
</script>
<style scoped>
.image-tab {
height: 100%;
display: flex;
overflow: auto;
background-color: #fff;
}
.image-container {
margin: auto;
padding: 16px;
}
.empty-state {
margin: auto;
color: #999;
}
</style>

View File

@ -1,24 +1,86 @@
<template> <template>
<div class="markdown-preview" v-html="htmlContent"></div> <div class="markdown-preview" v-html="htmlContent" @click="handleClick"></div>
<a-modal v-model:open="imagePreviewOpen" :footer="null" width="80vw" centered>
<img v-if="imagePreviewSrc" :src="imagePreviewSrc" style="max-width: 100%; max-height: 75vh; display: block; margin: 0 auto;" />
</a-modal>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
const props = defineProps({ const props = defineProps({
content: String content: String,
filePath: String
}); });
const isAbsoluteOrSpecialUrl = (rawUrl) => {
if (!rawUrl) return true;
if (rawUrl.startsWith('#')) return true;
if (rawUrl.startsWith('//')) return true;
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawUrl);
};
const getBaseDirFileUrl = (filePath) => {
if (!filePath) return '';
const normalized = filePath.replace(/\\/g, '/');
const lastSlashIndex = normalized.lastIndexOf('/');
const dirPath = lastSlashIndex >= 0 ? normalized.slice(0, lastSlashIndex + 1) : normalized;
return encodeURI(`file:///${dirPath}`);
};
const resolveToFileUrl = (rawUrl, baseDirFileUrl) => {
if (!rawUrl || !baseDirFileUrl) return rawUrl || '';
if (isAbsoluteOrSpecialUrl(rawUrl)) return rawUrl;
try {
return new URL(rawUrl, baseDirFileUrl).href;
} catch {
return rawUrl;
}
};
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, html: true,
linkify: true, linkify: true,
typographer: true typographer: true
}); });
const defaultImageRule = md.renderer.rules.image;
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const src = token.attrGet('src');
const resolved = resolveToFileUrl(src, env?.baseDirFileUrl);
if (resolved) token.attrSet('src', resolved);
if (defaultImageRule) return defaultImageRule(tokens, idx, options, env, self);
return self.renderToken(tokens, idx, options);
};
const defaultLinkOpenRule = md.renderer.rules.link_open;
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const href = token.attrGet('href');
const resolved = resolveToFileUrl(href, env?.baseDirFileUrl);
if (resolved) token.attrSet('href', resolved);
if (defaultLinkOpenRule) return defaultLinkOpenRule(tokens, idx, options, env, self);
return self.renderToken(tokens, idx, options);
};
const htmlContent = computed(() => { const htmlContent = computed(() => {
return md.render(props.content || ''); const env = { baseDirFileUrl: getBaseDirFileUrl(props.filePath) };
return md.render(props.content || '', env);
}); });
const imagePreviewOpen = ref(false);
const imagePreviewSrc = ref('');
const handleClick = (event) => {
const target = event?.target;
if (!target || target.tagName !== 'IMG') return;
const src = target.getAttribute('src') || '';
if (!src) return;
imagePreviewSrc.value = src;
imagePreviewOpen.value = true;
};
</script> </script>
<style> <style>

View File

@ -13,6 +13,11 @@
v-show="tab.id === state.activeTabId" v-show="tab.id === state.activeTabId"
:tab="tab" :tab="tab"
/> />
<ImageTab
v-else-if="tab.type === 'image'"
v-show="tab.id === state.activeTabId"
:tab="tab"
/>
</template> </template>
<div v-if="!activeTab" class="empty-state"> <div v-if="!activeTab" class="empty-state">
@ -26,6 +31,7 @@
import TabBar from './TabBar.vue'; import TabBar from './TabBar.vue';
import HomeTab from './HomeTab.vue'; import HomeTab from './HomeTab.vue';
import FileTab from './FileTab.vue'; import FileTab from './FileTab.vue';
import ImageTab from './ImageTab.vue';
import { useTabs } from '../composables/useTabs'; import { useTabs } from '../composables/useTabs';
const { state, activeTab } = useTabs(); const { state, activeTab } = useTabs();

View File

@ -32,10 +32,13 @@ export function useTabs() {
return; return;
} }
const isImageFile = (name) => /\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i.test(name || '');
const tabType = isImageFile(fileNode.name) ? 'image' : 'file';
// 创建新 Tab // 创建新 Tab
const newTab = { const newTab = {
id: uuidv4(), id: uuidv4(),
type: 'file', type: tabType,
title: fileNode.name, title: fileNode.name,
filePath: fileNode.path, filePath: fileNode.path,
content: '', // 内容稍后加载,或在组件挂载时加载 content: '', // 内容稍后加载,或在组件挂载时加载