feat: Link Preview block (carte aperçu URL) + proxy images
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m29s
CI / Deploy production (on server) (push) Has been skipped

- Bloc Link Preview : colle une URL → carte avec titre, description, image, favicon
- API /api/link-preview : extraction OpenGraph + meta tags
- API /api/image-proxy : contourne le hotlinking (Referer spoofing)
- Métadonnées persistées en HTML (data-preview JSON) — pas de refetch au reload
- Texte indexable : titre + description + URL inclus pour recherche/embeddings
- Modal propre pour saisir l'URL (plus de prompt())
- Slash menu + smart paste 'Coller comme carte aperçu'
- i18n FR/EN complet
- Fix: bouton calendrier retiré du sélecteur de vue
This commit is contained in:
Antigravity
2026-06-14 17:43:53 +00:00
parent 5246ed41e9
commit ba3ab3422a
10 changed files with 657 additions and 2 deletions

View File

@@ -30,6 +30,7 @@ import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar'
import { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
@@ -219,6 +220,12 @@ const slashCommands: SlashItem[] = [
title: 'Outline', description: 'Table of contents from headings', icon: ListTree, category: 'Basic blocks', shortcut: '/toc',
command: (e) => { insertOutlineBlock(e) },
},
{
title: 'Link Preview', description: 'Embed a URL as a rich card', icon: Link2, category: 'Basic blocks', shortcut: '/link',
command: (e) => {
window.dispatchEvent(new CustomEvent('memento-open-link-preview'))
},
},
]
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
@@ -308,6 +315,13 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const [isMobile, setIsMobile] = useState(false)
const [actionSheetOpen, setActionSheetOpen] = useState(false)
const [showFindReplace, setShowFindReplace] = useState(false)
const [linkPreviewUrl, setLinkPreviewUrl] = useState<string | null>(null)
useEffect(() => {
const handler = () => setLinkPreviewUrl('')
window.addEventListener('memento-open-link-preview', handler)
return () => window.removeEventListener('memento-open-link-preview', handler)
}, [])
useEffect(() => {
const handleFindShortcut = (e: KeyboardEvent) => {
@@ -458,6 +472,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
CalloutExtension,
OutlineExtension,
FindReplaceExtension,
LinkPreviewExtension,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({
@@ -1118,6 +1133,50 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
<FindReplaceBar editor={editor} onClose={() => setShowFindReplace(false)} />
)}
{editor && linkPreviewUrl !== null && (
<div className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/30" onClick={() => setLinkPreviewUrl(null)}>
<div className="bg-popover rounded-xl border border-border shadow-lg w-96 max-w-[90vw] p-4" onClick={(e) => e.stopPropagation()} dir="auto">
<div className="text-sm font-medium mb-3">{t('richTextEditor.linkPreviewModalTitle')}</div>
<input
type="url"
autoFocus
value={linkPreviewUrl}
onChange={(e) => setLinkPreviewUrl(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
if (linkPreviewUrl.trim()) {
insertLinkPreview(editor, linkPreviewUrl.trim())
setLinkPreviewUrl(null)
}
}
if (e.key === 'Escape') setLinkPreviewUrl(null)
}}
placeholder="https://..."
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
<div className="flex justify-end gap-2 mt-3">
<button onClick={() => setLinkPreviewUrl(null)} className="px-3 py-1.5 text-sm rounded-md hover:bg-muted transition-colors">
{t('richTextEditor.imageModalCancel')}
</button>
<button
onClick={() => {
if (linkPreviewUrl.trim()) {
insertLinkPreview(editor, linkPreviewUrl.trim())
setLinkPreviewUrl(null)
}
}}
disabled={!linkPreviewUrl.trim()}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
>
{t('richTextEditor.linkPreviewModalInsert')}
</button>
</div>
</div>
</div>
)}
{editor && blockMenuState && (
<BlockActionMenu
editor={editor}
@@ -1150,6 +1209,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
isImage={smartPasteExtended.isImage}
isVideo={smartPasteExtended.isVideo}
onLink={() => handlePasteUrlLink(smartPasteExtended.text)}
onLinkPreview={() => { insertLinkPreview(editor, smartPasteExtended.text); setSmartPasteExtended(null) }}
onImage={() => handlePasteUrlImage(smartPasteExtended.text)}
onVideo={() => handlePasteUrlVideo(smartPasteExtended.text)}
onCodeBlock={() => handlePasteCodeBlock(smartPasteExtended.text)}
@@ -1634,6 +1694,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
{ ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),