feat: Link Preview block (carte aperçu URL) + proxy images
- 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:
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user