feat(组件): 添加链接预览弹窗功能
- LinkPreviewModal: 浮岛浏览器风格链接预览组件 - 仿macOS窗口设计,三色圆点关闭按钮 - 地址栏显示域名和路径,支持点击复制 - 支持网页iframe嵌入预览 - 支持图片直接预览显示 - 加载状态和错误处理(不支持嵌入的站点) - 支持亮色/暗色主题自适应 - ESC关闭和新窗口打开功能 - MarkdownRenderer: 添加链接点击回调 - 新增 onLinkClick 属性支持拦截链接点击 - 图片链接在灯箱打开,普通链接在预览窗口打开
This commit is contained in:
parent
ecf11e6b2b
commit
2852f746f0
502
src/components/features/LinkPreviewModal.tsx
Normal file
502
src/components/features/LinkPreviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -11,6 +11,8 @@ interface MarkdownRendererProps {
|
||||
className?: string;
|
||||
/** 图片链接点击回调,用于在灯箱中打开图片 */
|
||||
onImageLinkClick?: (url: string) => void;
|
||||
/** 普通链接点击回调,用于在预览窗口中打开 */
|
||||
onLinkClick?: (url: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -28,7 +30,7 @@ function isImageUrl(url: string): boolean {
|
||||
* 创建 Markdown 组件配置
|
||||
* 使用工厂函数以支持传入回调
|
||||
*/
|
||||
function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||
function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLinkClick?: (url: string) => void) {
|
||||
return {
|
||||
// 代码块
|
||||
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 }) {
|
||||
// 如果是图片链接且有回调,则拦截点击事件
|
||||
if (href && onImageLinkClick && isImageUrl(href)) {
|
||||
@ -135,7 +137,22 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||
</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 (
|
||||
<a
|
||||
href={href}
|
||||
@ -242,11 +259,11 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
||||
}
|
||||
|
||||
// 使用 memo 包裹组件,避免不必要的重渲染
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick }: MarkdownRendererProps) {
|
||||
// 使用 useMemo 缓存 components 配置,仅在 onImageLinkClick 变化时重新创建
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick, onLinkClick }: MarkdownRendererProps) {
|
||||
// 使用 useMemo 缓存 components 配置,仅在回调变化时重新创建
|
||||
const components = useMemo(
|
||||
() => createMarkdownComponents(onImageLinkClick),
|
||||
[onImageLinkClick]
|
||||
() => createMarkdownComponents(onImageLinkClick, onLinkClick),
|
||||
[onImageLinkClick, onLinkClick]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user