'use client' import { Node, mergeAttributes } from '@tiptap/core' import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' import { Link2, Trash2, X, ExternalLink, Loader2 } from 'lucide-react' import { useState, useEffect, useRef } from 'react' import { cn } from '@/lib/utils' import { useLanguage } from '@/lib/i18n' interface PreviewData { title: string description: string image: string | null favicon: string | null siteName: string | null } const LinkPreviewView = ({ node, updateAttributes, deleteNode, selected }: any) => { const { t } = useLanguage() const url = node.attrs.url as string const cached = node.attrs.preview as PreviewData | null const [loading, setLoading] = useState(!cached && !!url) const [error, setError] = useState(false) const fetchedRef = useRef(null) useEffect(() => { if (!url || cached || fetchedRef.current === url) return fetchedRef.current = url setLoading(true) setError(false) fetch(`/api/link-preview?url=${encodeURIComponent(url)}`) .then(r => r.ok ? r.json() : null) .then(data => { if (data) { updateAttributes({ preview: data }) } else { setError(true) } }) .catch(() => setError(true)) .finally(() => setLoading(false)) }, [url, cached, updateAttributes]) const unwrap = () => { deleteNode() } const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return url } })() return (
{cached?.image && (
{ (e.target as HTMLImageElement).parentElement!.style.display = 'none' }} />
)}
{loading ? (
{t('richTextEditor.linkPreviewLoading')}
) : error ? ( ) : cached ? ( <> {cached.description && (

{cached.description}

)}
{cached.favicon && ( { (e.target as HTMLImageElement).style.display = 'none' }} /> )} {cached.siteName || domain}
) : null}
) } export const LinkPreviewExtension = Node.create({ name: 'linkPreviewBlock', group: 'block', atom: true, defining: true, isolating: true, addAttributes() { return { url: { default: '', parseHTML: (el) => el.getAttribute('data-url') || '', renderHTML: (attrs) => ({ 'data-url': attrs.url }), }, preview: { default: null, parseHTML: (el) => { const raw = el.getAttribute('data-preview') if (!raw) return null try { return JSON.parse(raw) } catch { return null } }, renderHTML: (attrs) => { if (!attrs.preview) return {} return { 'data-preview': JSON.stringify(attrs.preview) } }, }, } }, parseHTML() { return [{ tag: 'div[data-type="link-preview-block"]' }] }, renderHTML({ node, HTMLAttributes }) { const url = node.attrs.url || '' const preview = node.attrs.preview as PreviewData | null const searchText = preview ? `${preview.title || ''} ${preview.description || ''} ${url}`.trim() : url return [ 'div', mergeAttributes(HTMLAttributes, { 'data-type': 'link-preview-block', 'data-url': url, class: 'link-preview-block', }), ['div', { class: 'link-preview-searchable', style: 'display:none' }, searchText], ] }, addNodeView() { return ReactNodeViewRenderer(LinkPreviewView) }, }) export function insertLinkPreview(editor: any, url: string) { if (!url?.trim()) return editor.chain().focus().insertContent({ type: 'linkPreviewBlock', attrs: { url: url.trim(), preview: null }, }).run() } export function isUrl(text: string): boolean { return /^https?:\/\/[^\s]+$/i.test(text.trim()) }