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

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const url = req.nextUrl.searchParams.get('url')
if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 })
try {
const parsed = new URL(url)
if (!['http:', 'https:'].includes(parsed.protocol)) {
return NextResponse.json({ error: 'Invalid protocol' }, { status: 400 })
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const res = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MementoBot/1.0)',
'Accept': 'image/*',
'Referer': parsed.origin,
},
})
clearTimeout(timeout)
if (!res.ok) return NextResponse.json({ error: 'Fetch failed' }, { status: 502 })
const contentType = res.headers.get('content-type') || 'image/jpeg'
if (!contentType.startsWith('image/')) {
return NextResponse.json({ error: 'Not an image' }, { status: 400 })
}
const buffer = await res.arrayBuffer()
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, s-maxage=604800',
},
})
} catch {
return NextResponse.json({ error: 'Failed' }, { status: 502 })
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const url = req.nextUrl.searchParams.get('url')
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 })
}
try {
const parsed = new URL(url)
if (!['http:', 'https:'].includes(parsed.protocol)) {
return NextResponse.json({ error: 'Invalid protocol' }, { status: 400 })
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
const res = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MementoBot/1.0)',
'Accept': 'text/html',
},
redirect: 'follow',
})
clearTimeout(timeout)
if (!res.ok) {
return NextResponse.json({ error: 'Fetch failed' }, { status: 502 })
}
const html = await res.text()
const data = extractMetadata(html, parsed)
return NextResponse.json(data, {
headers: { 'Cache-Control': 'public, s-maxage=86400' },
})
} catch {
return NextResponse.json({ error: 'Failed to fetch' }, { status: 502 })
}
}
function extractMetadata(html: string, url: URL) {
const getMeta = (pattern: RegExp): string | null => {
const m = html.match(pattern)
return m?.[1]?.trim() || null
}
const getMetaProperty = (prop: string): string | null => {
return getMeta(new RegExp(`<meta[^>]+(?:property|name)=["']${prop}["'][^>]+content=["']([^"']+)["']`, 'i'))
|| getMeta(new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${prop}["']`, 'i'))
}
const title =
getMetaProperty('og:title') ||
getMeta(new RegExp('<title[^>]*>([^<]+)</title>', 'i')) ||
null
const description =
getMetaProperty('og:description') ||
getMetaProperty('description') ||
getMetaProperty('twitter:description') ||
null
const image =
getMetaProperty('og:image') ||
getMetaProperty('twitter:image') ||
null
const siteName =
getMetaProperty('og:site_name') ||
null
const favicon = image
? null
: `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`
const finalImage = image
? (image.startsWith('http') ? image : new URL(image, url.origin).href)
: null
return {
title: title ? decodeEntities(title) : null,
description: description ? decodeEntities(description).slice(0, 200) : null,
image: finalImage,
favicon,
siteName: siteName || url.hostname.replace('www.', ''),
}
}
function decodeEntities(text: string): string {
return text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&nbsp;/g, ' ')
}

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'),

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useLanguage } from '@/lib/i18n'
import { Link2, ImageIcon, Video, Code, FileText } from 'lucide-react'
import { Link2, ImageIcon, Video, Code, FileText, Layout } from 'lucide-react'
export type SmartPasteExtendedMenuProps = {
type: 'url' | 'code'
@@ -12,6 +12,7 @@ export type SmartPasteExtendedMenuProps = {
isImage?: boolean
isVideo?: boolean
onLink?: () => void
onLinkPreview?: () => void
onImage?: () => void
onVideo?: () => void
onCodeBlock?: () => void
@@ -26,6 +27,7 @@ export function SmartPasteExtendedMenu({
isImage,
isVideo,
onLink,
onLinkPreview,
onImage,
onVideo,
onCodeBlock,
@@ -90,6 +92,12 @@ export function SmartPasteExtendedMenu({
<Link2 size={15} className="text-blue-500" />
<span>{t('richTextEditor.smartPasteUrlLink') || 'Coller comme lien hypertexte'}</span>
</button>
{onLinkPreview && (
<button type="button" className="block-action-item" onClick={onLinkPreview}>
<Layout size={15} className="text-indigo-500" />
<span>{t('richTextEditor.smartPasteUrlPreview') || 'Coller comme carte aperçu'}</span>
</button>
)}
{isImage && onImage && (
<button type="button" className="block-action-item" onClick={onImage}>
<ImageIcon size={15} className="text-emerald-500" />

View File

@@ -0,0 +1,202 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import type { Note } from '@/lib/types'
import type { NotebookSchemaPayload, NotePropertyValues } from '@/lib/structured-views/types'
type NotesCalendarViewProps = {
notes: Note[]
schema: NotebookSchemaPayload
noteValues: Record<string, NotePropertyValues>
notebookColor?: string | null
onOpen: (note: Note) => void
onPropertyChange: (noteId: string, propertyId: string, value: unknown) => void
}
const WEEKDAYS_FR = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const WEEKDAYS_EN = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const MONTHS_FR = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
function getDateString(value: unknown): string | null {
if (!value || typeof value !== 'string') return null
const d = new Date(value)
if (isNaN(d.getTime())) return null
return d.toISOString().slice(0, 10)
}
export function NotesCalendarView({
notes,
schema,
noteValues,
notebookColor,
onOpen,
onPropertyChange,
}: NotesCalendarViewProps) {
const { t, language } = useLanguage()
const isFR = language === 'fr'
const weekdays = isFR ? WEEKDAYS_FR : WEEKDAYS_EN
const months = isFR ? MONTHS_FR : MONTHS_EN
const [currentMonth, setCurrentMonth] = useState(() => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 1)
})
const dateProperty = useMemo(() => {
return schema.properties.find(p => p.type === 'date')
}, [schema.properties])
const notesByDate = useMemo(() => {
const map = new Map<string, Note[]>()
if (!dateProperty) return map
for (const note of notes) {
const vals = noteValues[note.id]
if (!vals) continue
const raw = vals[dateProperty.id]
const dateStr = getDateString(raw)
if (!dateStr) continue
const existing = map.get(dateStr) || []
existing.push(note)
map.set(dateStr, existing)
}
return map
}, [notes, noteValues, dateProperty])
const calendarDays = useMemo(() => {
const year = currentMonth.getFullYear()
const month = currentMonth.getMonth()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const startWeekday = (firstDay.getDay() + 6) % 7
const daysInMonth = lastDay.getDate()
const days: Array<{ date: string | null; day: number | null; isToday: boolean }> = []
for (let i = 0; i < startWeekday; i++) {
days.push({ date: null, day: null, isToday: false })
}
const todayStr = new Date().toISOString().slice(0, 10)
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = new Date(year, month, d).toISOString().slice(0, 10)
days.push({ date: dateStr, day: d, isToday: dateStr === todayStr })
}
const remaining = (7 - (days.length % 7)) % 7
for (let i = 0; i < remaining; i++) {
days.push({ date: null, day: null, isToday: false })
}
return days
}, [currentMonth])
const prevMonth = useCallback(() => {
setCurrentMonth(m => new Date(m.getFullYear(), m.getMonth() - 1, 1))
}, [])
const nextMonth = useCallback(() => {
setCurrentMonth(m => new Date(m.getFullYear(), m.getMonth() + 1, 1))
}, [])
const todayMonth = useCallback(() => {
const now = new Date()
setCurrentMonth(new Date(now.getFullYear(), now.getMonth(), 1))
}, [])
if (!dateProperty) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center" dir="auto">
<p className="text-sm text-muted-foreground">
{t('structuredViews.calendarNoDateProperty')}
</p>
</div>
)
}
return (
<div className="w-full" dir="auto">
<div className="flex items-center justify-between mb-4 px-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">
{months[currentMonth.getMonth()]} {currentMonth.getFullYear()}
</h3>
</div>
<div className="flex items-center gap-1">
<button
onClick={todayMonth}
className="px-2.5 py-1 text-xs font-medium rounded-md hover:bg-muted transition-colors"
>
{isFR ? "Aujourd'hui" : 'Today'}
</button>
<button
onClick={prevMonth}
className="p-1 rounded-md hover:bg-muted transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={nextMonth}
className="p-1 rounded-md hover:bg-muted transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-px bg-border/30 rounded-lg overflow-hidden border border-border/30">
{weekdays.map(wd => (
<div key={wd} className="bg-muted/40 px-2 py-1.5 text-center text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{wd}
</div>
))}
{calendarDays.map((day, i) => {
const dayNotes = day.date ? (notesByDate.get(day.date) || []) : []
return (
<div
key={i}
className={cn(
'min-h-[80px] bg-background p-1 flex flex-col gap-0.5',
!day.date && 'bg-muted/20',
)}
>
{day.day && (
<span
className={cn(
'text-xs font-medium w-6 h-6 flex items-center justify-center rounded-full',
day.isToday
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground',
)}
>
{day.day}
</span>
)}
{dayNotes.slice(0, 3).map(note => (
<button
key={note.id}
onClick={() => onOpen(note)}
className="text-left text-[11px] leading-tight px-1.5 py-0.5 rounded bg-foreground/[0.04] hover:bg-primary/10 text-foreground/70 hover:text-primary transition-colors truncate"
>
{note.title || (isFR ? 'Sans titre' : 'Untitled')}
</button>
))}
{dayNotes.length > 3 && (
<span className="text-[10px] text-muted-foreground px-1.5">
+{dayNotes.length - 3}
</span>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import type { NotebookSchemaPayload, NotePropertyValues, StructuredViewMode } fr
import { NotesStructuredTable } from './notes-structured-table'
import { NotesKanbanView } from './notes-kanban-view'
import { NotesGalleryView } from './notes-gallery-view'
import { NotesCalendarView } from './notes-calendar-view'
type StructuredViewsContainerProps = {
mode: StructuredViewMode
@@ -101,5 +102,18 @@ export function StructuredViewsContainer({
)
}
if (mode === 'calendar') {
return (
<NotesCalendarView
notes={notes}
schema={schema}
noteValues={noteValues}
notebookColor={notebookColor}
onOpen={onOpen}
onPropertyChange={saveProperty}
/>
)
}
return null
}

View File

@@ -0,0 +1,206 @@
'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 = () => {
updateAttributes({ url: '', preview: null })
}
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())
}

View File

@@ -11,7 +11,7 @@ export type PropertyType = (typeof PROPERTY_TYPES)[number]
export const MAX_PROPERTIES_PER_NOTEBOOK = 15
export type StructuredViewMode = 'list' | 'table' | 'kanban' | 'gallery'
export type StructuredViewMode = 'list' | 'table' | 'kanban' | 'gallery' | 'calendar'
export type NotebookViewSettings = {
kanbanGroupPropertyId?: string | null

View File

@@ -2427,6 +2427,14 @@
"findWholeWord": "Whole word",
"findRegex": "Regular expression",
"findReplaceToggle": "Show/Hide replace",
"slashLinkPreview": "Link Preview",
"slashLinkPreviewDesc": "Turn a URL into a visual card",
"linkPreviewLoading": "Loading preview...",
"linkPreviewModalTitle": "Paste a link",
"linkPreviewModalInsert": "Create preview",
"linkPreviewUnwrap": "Revert to simple link",
"linkPreviewDelete": "Delete preview",
"smartPasteUrlPreview": "Paste as preview card",
"calloutDelete": "Delete callout",
"calloutUnwrap": "Disable callout",
"calloutInfo": "Information",
@@ -2611,6 +2619,8 @@
"viewKanban": "Kanban",
"viewGallery": "Gallery",
"viewKanbanHint": "Columns — like Trello to track your notes",
"viewCalendarHint": "Calendar — your notes organized by date",
"calendarNoDateProperty": "Add a date property to use the calendar view",
"viewGalleryHint": "Visual cards — browse your notes at a glance",
"intro": {
"databaseTitle": "Organized notebook",

View File

@@ -2431,6 +2431,14 @@
"findWholeWord": "Mot entier",
"findRegex": "Expression régulière",
"findReplaceToggle": "Afficher/Masquer le remplacement",
"slashLinkPreview": "Aperçu de lien",
"slashLinkPreviewDesc": "Transformer une URL en carte visuelle",
"linkPreviewLoading": "Chargement de l'aperçu...",
"linkPreviewModalTitle": "Coller un lien",
"linkPreviewModalInsert": "Créer l'aperçu",
"linkPreviewUnwrap": "Revenir au lien simple",
"linkPreviewDelete": "Supprimer l'aperçu",
"smartPasteUrlPreview": "Coller comme carte aperçu",
"calloutDelete": "Supprimer l'encadré",
"calloutUnwrap": "Désactiver l'encadré",
"calloutInfo": "Information",
@@ -2615,6 +2623,8 @@
"viewKanban": "Kanban",
"viewGallery": "Galerie",
"viewKanbanHint": "Colonnes — comme un tableau Trello pour faire avancer vos notes",
"viewCalendarHint": "Calendrier — vos notes organisées par date",
"calendarNoDateProperty": "Ajoutez une propriété de type date pour utiliser la vue calendrier",
"viewGalleryHint": "Cartes visuelles — parcourir vos notes en un coup d'œil",
"intro": {
"databaseTitle": "Base organisable",