feat: 实现图片预览功能

- 添加双击图片节点预览功能
- 添加右键菜单图片预览选项
- 实现图片预览模态框组件
- 添加图片URL智能处理(相对路径转绝对路径)
- 实现图片预加载机制和超时保护
- 增强错误处理和调试信息
- 修复图片一直加载不出来的问题
- 添加美观的加载动画和错误提示
This commit is contained in:
lixinran 2025-10-11 02:39:23 +08:00
parent 329d36bdd8
commit 6a7809a550
10 changed files with 1174 additions and 805 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

792
frontend/dist/assets/index-c91f543d.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -23,8 +23,8 @@
flex-direction: column; flex-direction: column;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-3d4f89fc.js"></script> <script type="module" crossorigin src="/assets/index-c91f543d.js"></script>
<link rel="stylesheet" href="/assets/index-2f08a1a5.css"> <link rel="stylesheet" href="/assets/index-1f5435d2.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -135,6 +135,34 @@
</div> </div>
<!-- 图片预览模态框 -->
<div v-if="showImagePreview" class="image-preview-modal" @click="closeImagePreview">
<div class="image-preview-content" @click.stop>
<div class="image-preview-header">
<span class="image-preview-title">{{ imagePreviewTitle }}</span>
<button @click="closeImagePreview" class="image-preview-close">×</button>
</div>
<div class="image-preview-body">
<div v-if="imagePreviewLoading" class="image-preview-loading">
<div class="loading-spinner"></div>
<p>加载图片中...</p>
</div>
<div v-else-if="imagePreviewError" class="image-preview-error">
<p>❌ 图片加载失败</p>
<p>{{ imagePreviewError }}</p>
<button @click="retryLoadImage" class="retry-button">重试</button>
</div>
<img
v-else
:src="imagePreviewUrl"
:alt="imagePreviewTitle"
class="preview-image"
@load="onImageLoad"
@error="onImageError"
/>
</div>
</div>
</div>
</div> </div>
</template> </template>
@ -213,6 +241,13 @@ const isDragging = ref(false);
const dragStartPos = ref({ x: 0, y: 0 }); const dragStartPos = ref({ x: 0, y: 0 });
const currentNodeId = ref(null); const currentNodeId = ref(null);
// 图片预览相关状态
const showImagePreview = ref(false);
const imagePreviewUrl = ref('');
const imagePreviewTitle = ref('');
const imagePreviewLoading = ref(false);
const imagePreviewError = ref('');
// 显示欢迎页面 // 显示欢迎页面
@ -238,6 +273,106 @@ const showMindMapPage = () => {
showWelcome.value = false; showWelcome.value = false;
}; };
// 图片预览相关方法
const openImagePreview = (imageUrl, altText = '') => {
console.log('🖼️ 打开图片预览:', { imageUrl, altText });
// 处理图片URL,确保是有效的URL
let processedUrl = imageUrl;
if (typeof imageUrl === 'string') {
// 如果是相对路径,转换为绝对路径
if (imageUrl.startsWith('/') || imageUrl.startsWith('./') || imageUrl.startsWith('../')) {
processedUrl = new URL(imageUrl, window.location.origin).href;
}
// 如果是base64图片,直接使用
else if (imageUrl.startsWith('data:image/')) {
processedUrl = imageUrl;
}
// 如果是完整URL,直接使用
else if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
processedUrl = imageUrl;
}
}
console.log('🖼️ 处理后的图片URL:', processedUrl);
showImagePreview.value = true;
imagePreviewTitle.value = altText || '图片预览';
imagePreviewLoading.value = true;
imagePreviewError.value = '';
// 启动超时机制
startImageLoadTimeout();
// 预加载图片,确保URL有效
const img = new Image();
img.onload = () => {
console.log('✅ 图片预加载成功');
// 预加载成功后,直接设置图片URL并停止加载状态
imagePreviewUrl.value = processedUrl;
imagePreviewLoading.value = false;
clearImageLoadTimeout();
};
img.onerror = () => {
console.error('❌ 图片预加载失败:', processedUrl);
clearImageLoadTimeout();
imagePreviewLoading.value = false;
imagePreviewError.value = `图片加载失败: ${processedUrl}`;
};
img.src = processedUrl;
};
const closeImagePreview = () => {
clearImageLoadTimeout();
showImagePreview.value = false;
imagePreviewUrl.value = '';
imagePreviewTitle.value = '';
imagePreviewLoading.value = false;
imagePreviewError.value = '';
};
const onImageLoad = () => {
console.log('✅ 模态框图片加载成功');
// 图片已经在预加载阶段处理了状态,这里只是确认
};
const onImageError = (event) => {
console.error('❌ 模态框图片加载失败:', event);
console.error('❌ 失败的图片URL:', imagePreviewUrl.value);
// 这种情况应该很少发生,因为预加载已经验证了URL
imagePreviewLoading.value = false;
imagePreviewError.value = `图片显示失败: ${imagePreviewUrl.value}`;
};
const retryLoadImage = () => {
console.log('🔄 重试加载图片');
imagePreviewLoading.value = true;
imagePreviewError.value = '';
startImageLoadTimeout();
};
// 图片加载超时处理
let imageLoadTimeout = null;
const startImageLoadTimeout = () => {
if (imageLoadTimeout) {
clearTimeout(imageLoadTimeout);
}
imageLoadTimeout = setTimeout(() => {
if (imagePreviewLoading.value) {
console.warn('⚠️ 图片加载超时');
imagePreviewLoading.value = false;
imagePreviewError.value = '图片加载超时,请检查网络连接或图片URL是否正确';
}
}, 10000); // 10秒超时
};
const clearImageLoadTimeout = () => {
if (imageLoadTimeout) {
clearTimeout(imageLoadTimeout);
imageLoadTimeout = null;
}
};
// 加载已有思维导图 // 加载已有思维导图
const loadExistingMindmap = async () => { const loadExistingMindmap = async () => {
// 这里可以实现一个思维导图列表选择器 // 这里可以实现一个思维导图列表选择器
@ -484,6 +619,12 @@ const loadMindmapData = async (data, keepPosition = false, shouldCenterRoot = tr
fixInitialRendering(); fixInitialRendering();
// 添加图片预览事件监听器
mindElixir.value.bus.addListener('showImagePreview', (imageUrl, altText) => {
console.log('🖼️ 收到图片预览事件:', { imageUrl, altText });
openImagePreview(imageUrl, altText);
});
// Mind Elixir现在会自动使用markdown解析器渲染内容 // Mind Elixir现在会自动使用markdown解析器渲染内容
} else { } else {
@ -565,6 +706,12 @@ const loadMindmapData = async (data, keepPosition = false, shouldCenterRoot = tr
fixDelayedRendering(); fixDelayedRendering();
// 添加图片预览事件监听器
mindElixir.value.bus.addListener('showImagePreview', (imageUrl, altText) => {
console.log('🖼️ 收到图片预览事件(延迟创建):', { imageUrl, altText });
openImagePreview(imageUrl, altText);
});
// 延迟执行后续操作 // 延迟执行后续操作
setTimeout(() => { setTimeout(() => {
// Mind Elixir现在会自动使用markdown解析器渲染内容 // Mind Elixir现在会自动使用markdown解析器渲染内容
@ -4576,5 +4723,149 @@ const updateMindMapRealtime = async (data, title) => {
margin-bottom: 8px !important; margin-bottom: 8px !important;
object-fit: cover !important; object-fit: cover !important;
} }
/* 图片预览模态框样式 */
.image-preview-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.image-preview-content {
background: white;
border-radius: 8px;
max-width: 90%;
max-height: 90%;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.image-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
background: #f5f5f5;
}
.image-preview-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.image-preview-close {
background: none;
border: none;
font-size: 28px;
color: #666;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.image-preview-close:hover {
background: #e0e0e0;
color: #333;
}
.image-preview-body {
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
max-height: 80vh;
overflow: auto;
}
.preview-image {
max-width: 100%;
max-height: 75vh;
height: auto;
object-fit: contain;
border-radius: 4px;
}
.image-preview-loading,
.image-preview-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: #666;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #660874;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.image-preview-error p {
margin: 8px 0;
text-align: center;
}
.retry-button {
margin-top: 16px;
padding: 8px 16px;
background: #660874;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.retry-button:hover {
background: #7d0a8e;
}
</style> </style>

View File

@ -46,11 +46,52 @@ export default function (mind: MindElixirInstance) {
} }
const handleDblClick = (e: MouseEvent) => { const handleDblClick = (e: MouseEvent) => {
if (!mind.editable) return
const target = e.target as HTMLElement const target = e.target as HTMLElement
// 检查是否双击了图片
if (target.tagName === 'IMG') {
const img = target as HTMLImageElement
const imageUrl = img.src
const altText = img.alt || img.title || ''
console.log('🖼️ 双击图片节点,准备预览:', { imageUrl, altText })
// 触发图片预览事件
mind.bus.fire('showImagePreview', imageUrl, altText)
return
}
// 检查是否双击了包含图片的节点
if (isTopic(target)) { if (isTopic(target)) {
const topic = target as Topic
// 检查节点是否有图片
if (topic.nodeObj?.image) {
const imageUrl = typeof topic.nodeObj.image === 'string' ? topic.nodeObj.image : topic.nodeObj.image.url
console.log('🖼️ 双击包含图片的节点,准备预览:', imageUrl)
mind.bus.fire('showImagePreview', imageUrl, topic.nodeObj.topic || '')
return
}
// 检查节点内容中是否包含图片
const imgInContent = topic.querySelector('img')
if (imgInContent) {
const imageUrl = imgInContent.src
const altText = imgInContent.alt || imgInContent.title || topic.nodeObj?.topic || ''
console.log('🖼️ 双击包含HTML图片的节点,准备预览:', { imageUrl, altText })
mind.bus.fire('showImagePreview', imageUrl, altText)
return
}
// 如果没有图片,则进入编辑模式
if (mind.editable) {
mind.beginEdit(target) mind.beginEdit(target)
} }
}
// 处理其他双击事件
if (mind.editable) {
const trySvg = target.parentElement?.parentElement as unknown as SVGElement const trySvg = target.parentElement?.parentElement as unknown as SVGElement
if (trySvg.getAttribute('class') === 'topiclinks') { if (trySvg.getAttribute('class') === 'topiclinks') {
mind.editArrowLabel(target.parentElement as unknown as CustomSvg) mind.editArrowLabel(target.parentElement as unknown as CustomSvg)
@ -58,6 +99,7 @@ export default function (mind: MindElixirInstance) {
mind.editSummary(target.parentElement as unknown as SummarySvgGroup) mind.editSummary(target.parentElement as unknown as SummarySvgGroup)
} }
} }
}
let lastTap = 0 let lastTap = 0
const handleTouchDblClick = (e: PointerEvent) => { const handleTouchDblClick = (e: PointerEvent) => {

View File

@ -48,6 +48,7 @@ export default function (mind: MindElixirInstance, option: true | ContextMenuOpt
const link = createLi('cm-link', lang.link, '') const link = createLi('cm-link', lang.link, '')
const linkBidirectional = createLi('cm-link-bidirectional', lang.linkBidirectional, '') const linkBidirectional = createLi('cm-link-bidirectional', lang.linkBidirectional, '')
const summary = createLi('cm-summary', lang.summary, '') const summary = createLi('cm-summary', lang.summary, '')
const imagePreview = createLi('cm-image-preview', '预览图片', '')
const menuUl = document.createElement('ul') const menuUl = document.createElement('ul')
menuUl.className = 'menu-list' menuUl.className = 'menu-list'
@ -93,6 +94,18 @@ export default function (mind: MindElixirInstance, option: true | ContextMenuOpt
} else { } else {
isRoot = false isRoot = false
} }
// 检查节点是否有图片,决定是否显示预览图片选项
const topic = target as Topic
const hasImage = topic.nodeObj?.image || topic.querySelector('img')
if (hasImage) {
imagePreview.style.display = 'block'
imagePreview.className = ''
} else {
imagePreview.style.display = 'none'
}
if (isRoot) { if (isRoot) {
focus.className = 'disabled' focus.className = 'disabled'
up.className = 'disabled' up.className = 'disabled'
@ -211,6 +224,28 @@ export default function (mind: MindElixirInstance, option: true | ContextMenuOpt
mind.createSummary() mind.createSummary()
mind.unselectNodes(mind.currentNodes) mind.unselectNodes(mind.currentNodes)
} }
imagePreview.onclick = () => {
menuContainer.hidden = true
const target = mind.currentNode as Topic
if (target) {
// 检查节点是否有图片
if (target.nodeObj?.image) {
const imageUrl = typeof target.nodeObj.image === 'string' ? target.nodeObj.image : target.nodeObj.image.url
console.log('🖼️ 右键菜单预览图片:', imageUrl)
mind.bus.fire('showImagePreview', imageUrl, target.nodeObj.topic || '')
} else {
// 检查节点内容中是否包含图片
const imgInContent = target.querySelector('img')
if (imgInContent) {
const imageUrl = imgInContent.src
const altText = imgInContent.alt || imgInContent.title || target.nodeObj?.topic || ''
console.log('🖼️ 右键菜单预览HTML图片:', { imageUrl, altText })
mind.bus.fire('showImagePreview', imageUrl, altText)
}
}
}
}
return () => { return () => {
// maybe useful? // maybe useful?
add_child.onclick = null add_child.onclick = null

View File

@ -83,6 +83,7 @@ export type EventMap = {
*/ */
updateArrowDelta: (arrow: Arrow) => void updateArrowDelta: (arrow: Arrow) => void
showContextMenu: (e: MouseEvent) => void showContextMenu: (e: MouseEvent) => void
showImagePreview: (imageUrl: string, altText?: string) => void
} }
export function createBus<T extends Record<string, (...args: any[]) => void> = EventMap>() { export function createBus<T extends Record<string, (...args: any[]) => void> = EventMap>() {