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 });
// URLURL
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>() {