Files
Momento/memento-note/components/tiptap-link-preview-extension.tsx
Antigravity ee70e74bf5
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Successful in 22s
fix: 5 bugs critiques de l'éditeur (Phase 1 audit)
1. replaceAll (Find & Replace) — une seule transaction ProseMirror
   au lieu d'un forEach cassé. Tous les matchs sont maintenant remplacés.

2. Link Preview unwrap — deleteNode() au lieu de clearer les attrs
   qui laissaient un nœud fantôme invisible dans le document.

3. Conversion Markdown → richtext — breaks: true dans marked.parse()
   Les simple newlines sont maintenant convertis en <br>.
   + préserve les blocs custom (toggle, callout, math, columns,
   outline, link-preview) en commentaires HTML lors de l'export MD.

4. emitNoteChange exercices — shape corrigée (type:'created' attend
   un objet Note, pas noteId/notebookId séparés).

5. Raccourcis clavier sans conflit :
   Cmd+Shift+C → Cmd+Alt+C (callout, avant: copier)
   Cmd+Shift+O → Cmd+Alt+O (outline, avant: historique/signets)
   Cmd+Shift+L → Cmd+Alt+L (colonnes, avant: lock screen macOS)
2026-06-20 15:48:18 +00:00

207 lines
6.9 KiB
TypeScript

'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<string | null>(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 (
<NodeViewWrapper className="link-preview-block my-2" dir="auto" data-selected={selected}>
<div className={cn(
'group relative rounded-xl border overflow-hidden transition-all',
'border-border bg-card hover:border-primary/40 hover:shadow-md',
selected && 'ring-2 ring-primary/30'
)}>
<div className="flex flex-col sm:flex-row">
{cached?.image && (
<div className="sm:w-48 h-32 sm:h-auto flex-shrink-0 bg-muted overflow-hidden">
<img
src={`/api/image-proxy?url=${encodeURIComponent(cached.image)}`}
alt=""
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).parentElement!.style.display = 'none' }}
/>
</div>
)}
<div className="flex-1 min-w-0 p-3.5">
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t('richTextEditor.linkPreviewLoading')}</span>
</div>
) : error ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link2 className="h-4 w-4 flex-shrink-0" />
<a href={url} target="_blank" rel="noopener noreferrer" className="truncate hover:underline text-primary">
{url}
</a>
</div>
) : cached ? (
<>
<div className="flex items-start justify-between gap-2 mb-1">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-sm leading-tight hover:text-primary transition-colors line-clamp-2"
>
{cached.title || domain}
</a>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
</div>
{cached.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2 leading-relaxed">
{cached.description}
</p>
)}
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{cached.favicon && (
<img src={`/api/image-proxy?url=${encodeURIComponent(cached.favicon)}`} alt="" className="w-3.5 h-3.5 rounded-sm" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
)}
<span className="truncate">{cached.siteName || domain}</span>
</div>
</>
) : null}
</div>
</div>
<div className="absolute top-1.5 right-1.5 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={unwrap}
contentEditable={false}
className="p-1 rounded-md bg-background/80 backdrop-blur hover:bg-muted text-muted-foreground"
title={t('richTextEditor.linkPreviewUnwrap')}
>
<X className="h-3.5 w-3.5" />
</button>
<button
onClick={deleteNode}
contentEditable={false}
className="p-1 rounded-md bg-background/80 backdrop-blur hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title={t('richTextEditor.linkPreviewDelete')}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</NodeViewWrapper>
)
}
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())
}