feat(组件): 添加链接预览弹窗功能

- LinkPreviewModal: 浮岛浏览器风格链接预览组件
  - 仿macOS窗口设计,三色圆点关闭按钮
  - 地址栏显示域名和路径,支持点击复制
  - 支持网页iframe嵌入预览
  - 支持图片直接预览显示
  - 加载状态和错误处理(不支持嵌入的站点)
  - 支持亮色/暗色主题自适应
  - ESC关闭和新窗口打开功能
- MarkdownRenderer: 添加链接点击回调
  - 新增 onLinkClick 属性支持拦截链接点击
  - 图片链接在灯箱打开,普通链接在预览窗口打开
This commit is contained in:
gaoziman 2025-12-22 22:00:08 +08:00
parent ecf11e6b2b
commit 2852f746f0
2 changed files with 526 additions and 7 deletions

View File

@ -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<HTMLIFrameElement>(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 (
<div
className="link-preview-overlay fixed inset-0 z-50 flex items-center justify-center p-6"
onClick={onClose}
>
{/* 弹窗容器 */}
<div
className="link-preview-modal relative flex flex-col w-[95vw] max-w-[1400px] h-[92vh] max-h-[960px] rounded-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 顶部标题栏 - 仿浏览器 */}
<div className="link-preview-header flex items-center gap-3.5 px-4 py-3.5 flex-shrink-0">
{/* 三色圆点 */}
<div className="flex gap-2 flex-shrink-0 pl-1">
<button
onClick={onClose}
className="w-3 h-3 rounded-full bg-gradient-to-br from-[#ff5f57] to-[#ff3b30] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)] hover:scale-110 transition-transform"
title="关闭"
/>
<div className="w-3 h-3 rounded-full bg-gradient-to-br from-[#ffcc00] to-[#febc2e] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]" />
<div className="w-3 h-3 rounded-full bg-gradient-to-br from-[#34d058] to-[#28c840] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]" />
</div>
{/* 地址栏 */}
<div
className="link-preview-url-bar flex-1 flex items-center gap-2.5 px-4 py-2.5 rounded-xl cursor-pointer"
onClick={copyLink}
title="点击复制链接"
>
{/* 安全锁图标 */}
<Lock size={14} className="flex-shrink-0 text-[#28c840]" />
{/* Favicon */}
{faviconUrl && (
<img
src={faviconUrl}
alt=""
className="w-4.5 h-4.5 rounded flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
{/* 域名 */}
<span className="link-preview-domain text-sm font-semibold whitespace-nowrap">
{linkType === 'image' ? '图片预览' : domain}
</span>
{/* 路径 */}
<span className="link-preview-path text-sm whitespace-nowrap overflow-hidden text-ellipsis flex-1">
{urlPath}
</span>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1.5 flex-shrink-0">
{/* 复制链接 */}
<button
onClick={copyLink}
className={cn(
'link-preview-action-btn w-9 h-9 flex items-center justify-center rounded-xl transition-all',
copied && 'text-[#28c840]!'
)}
title={copied ? '已复制' : '复制链接'}
>
{copied ? <Check size={18} /> : <Copy size={18} />}
</button>
{/* 新窗口打开 */}
<button
onClick={openInNewWindow}
className="link-preview-action-btn w-9 h-9 flex items-center justify-center rounded-xl transition-all"
title="在新窗口打开"
>
<ExternalLink size={18} />
</button>
{/* 关闭按钮 */}
<button
onClick={onClose}
className="link-preview-action-btn w-9 h-9 flex items-center justify-center rounded-xl transition-all"
title="关闭 (ESC)"
>
<X size={18} />
</button>
</div>
</div>
{/* 内容预览区域 */}
<div className="link-preview-content relative flex-1 min-h-0 overflow-hidden">
{/* 加载状态 */}
{isLoading && (
<div className="link-preview-loading absolute inset-0 flex flex-col items-center justify-center z-10 gap-5">
<div className="w-11 h-11 border-3 border-gray-200 border-t-[#6366f1] rounded-full animate-spin" />
<span className="text-[15px] font-medium text-gray-500">...</span>
</div>
)}
{/* 加载失败 */}
{loadError && (
<div className="link-preview-error absolute inset-0 flex flex-col items-center justify-center z-10 gap-4 px-10">
<div className="w-[72px] h-[72px] rounded-full bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center">
<AlertCircle size={32} className="text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-800">
{linkType === 'image' ? '图片加载失败' : '该网站不支持嵌入预览'}
</h3>
<p className="text-sm text-gray-500 text-center max-w-[360px] leading-relaxed">
{linkType === 'image'
? '图片可能已失效或无法访问'
: '此网站设置了安全策略,禁止被嵌入到其他页面中。您可以在新窗口中查看完整内容。'}
</p>
<button
onClick={openInNewWindow}
className="mt-2 px-7 py-3 bg-gradient-to-br from-[#6366f1] to-[#4f46e5] text-white rounded-xl text-[15px] font-semibold flex items-center gap-2 shadow-[0_4px_14px_-3px_rgba(99,102,241,0.5)] hover:translate-y-[-2px] hover:shadow-[0_6px_20px_-3px_rgba(99,102,241,0.6)] transition-all"
>
<ExternalLink size={18} />
</button>
</div>
)}
{/* 图片预览 */}
{linkType === 'image' && (
<div className="w-full h-full flex items-center justify-center p-8 bg-gradient-to-b from-gray-50 to-gray-100">
<img
src={url}
alt="图片预览"
className={cn(
'max-w-full max-h-full object-contain rounded-xl shadow-[0_8px_32px_-8px_rgba(0,0,0,0.2)] transition-opacity duration-300',
isLoading ? 'opacity-0' : 'opacity-100'
)}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
)}
{/* 网页预览 (iframe) */}
{linkType === 'webpage' && !loadError && (
<iframe
ref={iframeRef}
src={url}
className={cn(
'w-full h-full border-none transition-opacity duration-300',
isLoading ? 'opacity-0' : 'opacity-100'
)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
onLoad={handleIframeLoad}
onError={handleIframeError}
title="网页预览"
/>
)}
</div>
{/* 底部状态栏 */}
<div className="link-preview-footer px-5 py-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2 text-[13px] text-gray-400">
<span className="w-1.5 h-1.5 rounded-full bg-[#28c840] animate-pulse" />
<span>{linkType === 'image' ? '图片预览' : '网页预览'}</span>
</div>
<div className="flex items-center gap-2 text-[13px] text-gray-400">
<span></span>
<kbd className="px-2 py-1 bg-black/5 rounded-md text-xs font-medium text-gray-500 border border-black/5">
ESC
</kbd>
<span></span>
</div>
</div>
</div>
{/* 样式 */}
<style jsx global>{`
/* 遮罩层 */
.link-preview-overlay {
background: rgba(30, 30, 40, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
animation: linkPreviewFadeIn 0.25s ease-out;
}
@keyframes linkPreviewFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 弹窗容器 */
.link-preview-modal {
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 25px 80px -12px rgba(0, 0, 0, 0.3),
0 12px 40px -8px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.6);
animation: linkPreviewScaleIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes linkPreviewScaleIn {
from {
opacity: 0;
transform: scale(0.92) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 顶部标题栏 */
.link-preview-header {
background: linear-gradient(180deg, #f8f9fa 0%, #f1f3f4 100%);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
/* 地址栏 */
.link-preview-url-bar {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.04);
transition: background 0.2s;
}
.link-preview-url-bar:hover {
background: rgba(0, 0, 0, 0.07);
}
.link-preview-domain {
color: #1a1a2e;
}
.link-preview-path {
color: #9ca3af;
}
/* 操作按钮 */
.link-preview-action-btn {
color: #6b7280;
}
.link-preview-action-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #1a1a2e;
}
.link-preview-action-btn:active {
transform: scale(0.95);
}
/* 内容区域 */
.link-preview-content {
background: #ffffff;
}
/* 加载状态背景 */
.link-preview-loading {
background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
}
/* 错误状态背景 */
.link-preview-error {
background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
}
/* 底部状态栏 */
.link-preview-footer {
background: linear-gradient(180deg, #f8f9fa 0%, #f1f3f4 100%);
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.link-preview-modal {
background: rgba(30, 32, 40, 0.98);
border-color: rgba(255, 255, 255, 0.1);
}
.link-preview-header {
background: linear-gradient(180deg, #1e2028 0%, #252830 100%);
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.link-preview-url-bar {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.05);
}
.link-preview-url-bar:hover {
background: rgba(255, 255, 255, 0.12);
}
.link-preview-domain {
color: #f1f5f9;
}
.link-preview-path {
color: #64748b;
}
.link-preview-action-btn {
color: #94a3b8;
}
.link-preview-action-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f1f5f9;
}
.link-preview-content {
background: #1a1b1e;
}
.link-preview-loading {
background: linear-gradient(180deg, #1e2028 0%, #252830 100%);
}
.link-preview-error {
background: linear-gradient(180deg, #1e2028 0%, #252830 100%);
}
.link-preview-error h3 {
color: #f1f5f9;
}
.link-preview-error p {
color: #94a3b8;
}
.link-preview-footer {
background: linear-gradient(180deg, #1e2028 0%, #252830 100%);
border-top-color: rgba(255, 255, 255, 0.08);
}
.link-preview-footer span,
.link-preview-footer kbd {
color: #64748b;
}
}
`}</style>
</div>
);
}

View File

@ -11,6 +11,8 @@ interface MarkdownRendererProps {
className?: string; className?: string;
/** 图片链接点击回调,用于在灯箱中打开图片 */ /** 图片链接点击回调,用于在灯箱中打开图片 */
onImageLinkClick?: (url: string) => void; onImageLinkClick?: (url: string) => void;
/** 普通链接点击回调,用于在预览窗口中打开 */
onLinkClick?: (url: string) => void;
} }
/** /**
@ -28,7 +30,7 @@ function isImageUrl(url: string): boolean {
* Markdown * Markdown
* 使 * 使
*/ */
function createMarkdownComponents(onImageLinkClick?: (url: string) => void) { function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLinkClick?: (url: string) => void) {
return { return {
// 代码块 // 代码块
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) { code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
@ -118,7 +120,7 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
); );
}, },
// 链接 - 支持图片链接在灯箱中打开 // 链接 - 支持图片链接在灯箱中打开,普通链接在预览窗口打开
a({ href, children }: { href?: string; children?: React.ReactNode }) { a({ href, children }: { href?: string; children?: React.ReactNode }) {
// 如果是图片链接且有回调,则拦截点击事件 // 如果是图片链接且有回调,则拦截点击事件
if (href && onImageLinkClick && isImageUrl(href)) { if (href && onImageLinkClick && isImageUrl(href)) {
@ -135,7 +137,22 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
</a> </a>
); );
} }
// 非图片链接保持原有行为 // 普通链接 - 如果有 onLinkClick 回调,则拦截点击事件在预览窗口打开
if (href && onLinkClick) {
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
onLinkClick(href);
}}
className="text-[var(--color-primary)] hover:underline cursor-pointer"
>
{children}
</a>
);
}
// 没有回调时保持原有行为
return ( return (
<a <a
href={href} href={href}
@ -242,11 +259,11 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
} }
// 使用 memo 包裹组件,避免不必要的重渲染 // 使用 memo 包裹组件,避免不必要的重渲染
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick }: MarkdownRendererProps) { export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick, onLinkClick }: MarkdownRendererProps) {
// 使用 useMemo 缓存 components 配置,仅在 onImageLinkClick 变化时重新创建 // 使用 useMemo 缓存 components 配置,仅在回调变化时重新创建
const components = useMemo( const components = useMemo(
() => createMarkdownComponents(onImageLinkClick), () => createMarkdownComponents(onImageLinkClick, onLinkClick),
[onImageLinkClick] [onImageLinkClick, onLinkClick]
); );
return ( return (