From 2852f746f0f46228e256aa4160559377e343f769 Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Mon, 22 Dec 2025 22:00:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BB=84=E4=BB=B6):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E9=A2=84=E8=A7=88=E5=BC=B9=E7=AA=97=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LinkPreviewModal: 浮岛浏览器风格链接预览组件 - 仿macOS窗口设计,三色圆点关闭按钮 - 地址栏显示域名和路径,支持点击复制 - 支持网页iframe嵌入预览 - 支持图片直接预览显示 - 加载状态和错误处理(不支持嵌入的站点) - 支持亮色/暗色主题自适应 - ESC关闭和新窗口打开功能 - MarkdownRenderer: 添加链接点击回调 - 新增 onLinkClick 属性支持拦截链接点击 - 图片链接在灯箱打开,普通链接在预览窗口打开 --- src/components/features/LinkPreviewModal.tsx | 502 +++++++++++++++++++ src/components/markdown/MarkdownRenderer.tsx | 31 +- 2 files changed, 526 insertions(+), 7 deletions(-) create mode 100644 src/components/features/LinkPreviewModal.tsx diff --git a/src/components/features/LinkPreviewModal.tsx b/src/components/features/LinkPreviewModal.tsx new file mode 100644 index 0000000..4e38d0c --- /dev/null +++ b/src/components/features/LinkPreviewModal.tsx @@ -0,0 +1,502 @@ +'use client'; + +import { useEffect, useState, useCallback, useRef } from 'react'; +import { X, ExternalLink, AlertCircle, Copy, Check, Loader2, Lock } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface LinkPreviewModalProps { + url: string | null; + isOpen: boolean; + onClose: () => void; +} + +type LinkType = 'webpage' | 'image' | 'unknown'; + +/** + * 检测链接类型 + */ +function detectLinkType(url: string): LinkType { + const lowerUrl = url.toLowerCase(); + + // 图片链接检测 + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico']; + if (imageExtensions.some(ext => lowerUrl.includes(ext))) { + return 'image'; + } + + // 图片托管服务 + const imageHosts = ['imgur.com', 'i.imgur.com', 'images.unsplash.com', 'picsum.photos']; + if (imageHosts.some(host => lowerUrl.includes(host))) { + return 'image'; + } + + return 'webpage'; +} + +/** + * 获取网站 favicon URL + */ +function getFaviconUrl(url: string): string { + try { + const urlObj = new URL(url); + return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`; + } catch { + return ''; + } +} + +/** + * 获取域名 + */ +function getDomain(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + return url; + } +} + +/** + * 获取 URL 路径 + */ +function getUrlPath(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.pathname + urlObj.search; + } catch { + return ''; + } +} + +/** + * 链接预览弹窗组件 + * 浮岛浏览器风格 - 浅色主题 + */ +export function LinkPreviewModal({ + url, + isOpen, + onClose, +}: LinkPreviewModalProps) { + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [copied, setCopied] = useState(false); + const iframeRef = useRef(null); + + // 重置状态 + useEffect(() => { + if (isOpen && url) { + setIsLoading(true); + setLoadError(false); + setCopied(false); + } + }, [isOpen, url]); + + // 键盘事件处理 + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [isOpen, onClose]); + + // 打开原链接 + const openInNewWindow = useCallback(() => { + if (url) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }, [url]); + + // 复制链接 + const copyLink = useCallback(async () => { + if (url) { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('复制失败:', err); + } + } + }, [url]); + + // iframe 加载完成 + const handleIframeLoad = useCallback(() => { + setIsLoading(false); + }, []); + + // iframe 加载错误 + const handleIframeError = useCallback(() => { + setIsLoading(false); + setLoadError(true); + }, []); + + // 图片加载完成 + const handleImageLoad = useCallback(() => { + setIsLoading(false); + }, []); + + // 图片加载错误 + const handleImageError = useCallback(() => { + setIsLoading(false); + setLoadError(true); + }, []); + + if (!isOpen || !url) return null; + + const linkType = detectLinkType(url); + const domain = getDomain(url); + const urlPath = getUrlPath(url); + const faviconUrl = getFaviconUrl(url); + + return ( +
+ {/* 弹窗容器 */} +
e.stopPropagation()} + > + {/* 顶部标题栏 - 仿浏览器 */} +
+ {/* 三色圆点 */} +
+ + + {/* 新窗口打开 */} + + + {/* 关闭按钮 */} + +
+
+ + {/* 内容预览区域 */} +
+ {/* 加载状态 */} + {isLoading && ( +
+
+ 正在加载预览... +
+ )} + + {/* 加载失败 */} + {loadError && ( +
+
+ +
+

+ {linkType === 'image' ? '图片加载失败' : '该网站不支持嵌入预览'} +

+

+ {linkType === 'image' + ? '图片可能已失效或无法访问' + : '此网站设置了安全策略,禁止被嵌入到其他页面中。您可以在新窗口中查看完整内容。'} +

+ +
+ )} + + {/* 图片预览 */} + {linkType === 'image' && ( +
+ 图片预览 +
+ )} + + {/* 网页预览 (iframe) */} + {linkType === 'webpage' && !loadError && ( +