feat(editor): implement next-gen editor with unique gutter drag handle, block actions menu, smart paste transclusion, and redesigned inline structured view block (US-NEXTGEN-EDITOR, US-4)

This commit is contained in:
Antigravity
2026-05-27 21:39:21 +00:00
parent 493108f957
commit 07ace46dd3
17 changed files with 2402 additions and 619 deletions

View File

@@ -14,7 +14,7 @@ import {
Heading1, Heading2, Heading3, List, ListOrdered,
CheckSquare, Quote, CodeXml, Database,
} from 'lucide-react'
import { replaceBlockWithDatabase } from '@/components/tiptap-database-block-extension'
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
interface BlockActionMenuProps {
editor: Editor
@@ -132,7 +132,8 @@ export function BlockActionMenu({
const handleTurnInto = useCallback((option: TurnIntoOption) => {
if (blockPos >= 0 && blockNode) {
if (option.isDatabase) {
replaceBlockWithDatabase(editor, blockPos, blockNode)
const notebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
replaceBlockWithStructuredView(editor, blockPos, blockNode, notebookId)
} else if (option.command) {
focusBlock(editor, blockPos)
option.command(editor)

View File

@@ -1,255 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { Trash2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import {
type DatabaseBlockData,
randomDefaultCover,
} from '@/lib/editor/database-block-types'
interface DatabaseBlockEditorProps {
data: DatabaseBlockData
readOnly?: boolean
onChange: (data: DatabaseBlockData) => void
}
export function DatabaseBlockEditor({ data, readOnly, onChange }: DatabaseBlockEditorProps) {
const { t } = useLanguage()
const { dbId, dbView, dbAuthors, dbBooks } = data
const [newBookTitle, setNewBookTitle] = useState('')
const [newBookAuthor, setNewBookAuthor] = useState('')
const [newBookTag, setNewBookTag] = useState('')
const [newBookCover, setNewBookCover] = useState('')
const [newAuthorName, setNewAuthorName] = useState('')
const patch = useCallback((partial: Partial<DatabaseBlockData>) => {
onChange({ ...data, ...partial })
}, [data, onChange])
const handleAddAuthor = useCallback(() => {
const name = newAuthorName.trim()
if (!name) return
if (dbAuthors.some((a) => a.name.toLowerCase() === name.toLowerCase())) return
patch({
dbAuthors: [...dbAuthors, { id: `auth-${Date.now()}`, name }],
})
setNewAuthorName('')
}, [dbAuthors, newAuthorName, patch])
const handleAddBook = useCallback((e: React.FormEvent) => {
e.preventDefault()
const title = newBookTitle.trim()
let author = newBookAuthor.trim()
if (!title || !author) return
let nextAuthors = [...dbAuthors]
if (author === '__new__') return
if (!nextAuthors.some((a) => a.name.toLowerCase() === author.toLowerCase())) {
nextAuthors = [...nextAuthors, { id: `auth-${Date.now()}`, name: author }]
}
patch({
dbAuthors: nextAuthors,
dbBooks: [
...dbBooks,
{
id: `bk-${Date.now()}`,
title,
author,
cover: newBookCover.trim() || randomDefaultCover(),
tag: newBookTag.trim() || t('databaseBlock.defaultTag'),
},
],
})
setNewBookTitle('')
setNewBookAuthor('')
setNewBookTag('')
setNewBookCover('')
}, [dbAuthors, dbBooks, newBookAuthor, newBookCover, newBookTag, newBookTitle, patch, t])
const handleDeleteBook = useCallback((id: string) => {
patch({ dbBooks: dbBooks.filter((b) => b.id !== id) })
}, [dbBooks, patch])
const handleDeleteAuthor = useCallback((id: string) => {
patch({ dbAuthors: dbAuthors.filter((a) => a.id !== id) })
}, [dbAuthors, patch])
return (
<div className="database-block not-prose my-4 text-left">
<div className="database-block__inner">
<div className="database-block__header">
<div className="flex items-center gap-2 min-w-0">
<span className="text-lg shrink-0" aria-hidden>📚</span>
<div className="min-w-0">
<span className="database-block__title">{t('databaseBlock.title')}</span>
<span className="database-block__id">id: {dbId}</span>
</div>
</div>
{!readOnly && (
<div className="database-block__view-toggle">
<button
type="button"
onClick={() => patch({ dbView: 'table' })}
className={dbView === 'table' ? 'is-active' : ''}
>
{t('databaseBlock.viewTable')}
</button>
<button
type="button"
onClick={() => patch({ dbView: 'card' })}
className={dbView === 'card' ? 'is-active' : ''}
>
{t('databaseBlock.viewCards')}
</button>
</div>
)}
</div>
<p className="database-block__hint">{t('databaseBlock.hint')}</p>
{dbView === 'table' ? (
<div className="space-y-4">
<div className="database-block__table-wrap">
<table className="database-block__table">
<thead>
<tr>
<th>{t('databaseBlock.colAuthor')}</th>
<th>{t('databaseBlock.colWorks')}</th>
<th className="text-right">{t('databaseBlock.colRollup')}</th>
{!readOnly && <th className="w-12" />}
</tr>
</thead>
<tbody>
{dbAuthors.map((auth) => {
const authorBooks = dbBooks.filter(
(b) => b.author.toLowerCase() === auth.name.toLowerCase(),
)
const worksStr = authorBooks.map((b) => b.title).join(', ')
|| t('databaseBlock.noLinkedWorks')
return (
<tr key={auth.id}>
<td className="font-semibold">{auth.name}</td>
<td className="database-block__works-cell" title={worksStr}>{worksStr}</td>
<td className="database-block__rollup">{authorBooks.length}</td>
{!readOnly && (
<td className="text-center">
<button
type="button"
onClick={() => handleDeleteAuthor(auth.id)}
className="database-block__delete-btn"
>
{t('databaseBlock.deleteShort')}
</button>
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
{!readOnly && (
<div className="database-block__inline-form">
<span className="database-block__form-label">{t('databaseBlock.addAuthor')}</span>
<input
type="text"
placeholder={t('databaseBlock.authorPlaceholder')}
value={newAuthorName}
onChange={(e) => setNewAuthorName(e.target.value)}
className="database-block__input flex-1"
/>
<button type="button" onClick={handleAddAuthor} className="database-block__primary-btn">
{t('databaseBlock.createAuthor')}
</button>
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{dbBooks.map((book) => (
<div key={book.id} className="database-block__card group/book">
{!readOnly && (
<button
type="button"
onClick={() => handleDeleteBook(book.id)}
className="database-block__card-delete"
title={t('databaseBlock.deleteCard')}
>
<Trash2 size={11} />
</button>
)}
<div className="database-block__card-cover">
<img src={book.cover} alt={book.title} referrerPolicy="no-referrer" />
</div>
<div className="database-block__card-body">
<p className="database-block__card-title" title={book.title}>{book.title}</p>
<div className="flex flex-wrap gap-1 pt-1">
<span className="database-block__tag database-block__tag--author">{book.author}</span>
<span className="database-block__tag database-block__tag--genre">{book.tag}</span>
</div>
</div>
</div>
))}
<div className="database-block__card-placeholder">
<span className="text-xl mb-1" aria-hidden>📖</span>
<span className="font-bold text-[10px] uppercase tracking-widest">{t('databaseBlock.worksBase')}</span>
<span className="text-[8.5px] text-zinc-400 mt-1">
{t('databaseBlock.storedCount', { count: dbBooks.length })}
</span>
</div>
</div>
)}
{!readOnly && (
<form onSubmit={handleAddBook} className="database-block__book-form">
<span className="database-block__form-heading">{t('databaseBlock.addWork')}</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
type="text"
placeholder={t('databaseBlock.bookTitlePlaceholder')}
value={newBookTitle}
onChange={(e) => setNewBookTitle(e.target.value)}
className="database-block__input"
required
/>
<select
value={newBookAuthor}
onChange={(e) => setNewBookAuthor(e.target.value)}
className="database-block__input"
required
>
<option value="">{t('databaseBlock.selectAuthor')}</option>
{dbAuthors.map((auth) => (
<option key={auth.id} value={auth.name}>{auth.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
type="text"
placeholder={t('databaseBlock.tagPlaceholder')}
value={newBookTag}
onChange={(e) => setNewBookTag(e.target.value)}
className="database-block__input"
/>
<input
type="text"
placeholder={t('databaseBlock.coverPlaceholder')}
value={newBookCover}
onChange={(e) => setNewBookCover(e.target.value)}
className="database-block__input"
/>
</div>
<button type="submit" className="database-block__submit">
{t('databaseBlock.insertWork')}
</button>
</form>
)}
</div>
</div>
)
}

View File

@@ -107,7 +107,8 @@ export function NoteContentArea() {
className="min-h-[280px]"
onImageUpload={uploadImageFile}
noteId={note.id}
noteTitle={state.title || note.title}
notebookId={note.notebookId}
noteTitle={state.title || note.title || undefined}
sourceUrl={note.sourceUrl}
/>
</div>
@@ -123,7 +124,8 @@ export function NoteContentArea() {
className="min-h-[200px]"
onImageUpload={uploadImageFile}
noteId={note.id}
noteTitle={state.title || note.title}
notebookId={note.notebookId}
noteTitle={state.title || note.title || undefined}
sourceUrl={note.sourceUrl}
/>
<GhostTags

View File

@@ -25,7 +25,7 @@ import { ChartExtension } from './tiptap-chart-extension'
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
import { UniqueIdExtension } from './tiptap-unique-id-extension'
import { LiveBlockExtension } from './tiptap-live-block-extension'
import { DatabaseBlockExtension, insertDatabaseBlockAtSelection } from './tiptap-database-block-extension'
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
@@ -69,13 +69,14 @@ export interface RichTextEditorHandle {
insertLiveBlock: (block: BlockSuggestion, options?: { atEnd?: boolean }) => boolean
}
interface RichTextEditorProps {
export interface RichTextEditorProps {
content?: string
onChange?: (content: string) => void
className?: string
placeholder?: string
onImageUpload?: (file: File) => Promise<string>
noteId?: string
notebookId?: string | null
noteTitle?: string
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
sourceUrl?: string | null
@@ -190,12 +191,12 @@ const slashCommands: SlashItem[] = [
}
},
{
title: 'Database', description: 'Inline authors & works database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertDatabaseBlockAtSelection(e) },
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertStructuredViewBlockAtSelection(e) },
},
]
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
const res = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -250,7 +251,7 @@ function useImageInsert() {
}
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, noteTitle, sourceUrl }, ref) {
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
@@ -392,7 +393,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
...globalDragHandleExtensions,
SmartPasteExtension,
LiveBlockExtension,
DatabaseBlockExtension,
StructuredViewBlockExtension,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
@@ -409,15 +410,15 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const { from, empty } = view.state.selection
if (!empty) return false
const textBefore = view.state.doc.textBetween(Math.max(0, from - 32), from, '\n')
if (!/\/(database|db)$/i.test(textBefore)) return false
if (!/\/(database|db|vue|tableau|structured)$/i.test(textBefore)) return false
event.preventDefault()
const slashIdx = textBefore.lastIndexOf('/')
const deleteFrom = from - (textBefore.length - slashIdx)
const ed = editorInstanceRef.current
if (!ed) return false
ed.chain().focus().deleteRange({ from: deleteFrom, to: from }).run()
if (!insertDatabaseBlockAtSelection(ed)) {
toast.error(t('databaseBlock.insertFailed'))
if (!insertStructuredViewBlockAtSelection(ed, notebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
}
return true
},
@@ -509,6 +510,13 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
editor.storage.liveBlock.hostNoteId = noteId ?? null
}, [editor, noteId])
useEffect(() => {
if (!editor) return
if ((editor.storage as any).structuredViewBlock) {
(editor.storage as any).structuredViewBlock.notebookId = notebookId ?? null
}
}, [editor, notebookId])
useEffect(() => {
if (!editor) return
@@ -516,7 +524,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const clipboardText = event.clipboardData?.getData('text/plain') ?? ''
let blockRef = parseBlockReferenceFromText(clipboardText)
if (!blockRef) {
blockRef = recallLastBlockReference()
const recalled = recallLastBlockReference()
if (recalled && (!clipboardText.trim() || clipboardText.trim() === recalled.raw)) {
blockRef = recalled
}
}
if (!blockRef) return false
@@ -961,7 +972,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
)}
{chartSuggestionsOpen && (
{chartSuggestionsOpen && editor && (
<ChartSuggestionsDialog
isOpen={chartSuggestionsOpen}
content={currentNoteContent}
@@ -1096,7 +1107,7 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
setTranslateOpen(false)
try {
const lang = option === 'translate' ? (targetLang || language) : language
const result = await aiReformulate(text, option, lang)
const result = await aiReformulate(text, option, t, lang)
window.dispatchEvent(new Event('ai-usage-changed'))
if (option === 'explain') {
setAiModal({ type: 'explain', origText: text, html: result, from, to })
@@ -1283,7 +1294,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'basic' },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees'] },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1323,7 +1334,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
if (!consented) return
const allText = editor.state.doc.textContent
if (!allText || allText.split(/\s+/).length < 5) return
const result = await aiReformulate(allText, item.aiOption)
const result = await aiReformulate(allText, item.aiOption, t)
editor.chain().focus().setContent(result).run()
} catch (err) {
console.error('AI slash error:', err)
@@ -1334,8 +1345,9 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu()
if (!insertDatabaseBlockAtSelection(editor)) {
toast.error(t('databaseBlock.insertFailed'))
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
}
} else {
deleteSlashText(); item.command(editor); closeMenu()
@@ -1394,11 +1406,12 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const item = filtered[selectedIndex]
if (item) {
handleSelect(item)
} else if (/^(database|db)$/i.test(query)) {
} else if (/^(database|db|vue|tableau|structured)$/i.test(query)) {
deleteSlashText()
closeMenu()
if (!insertDatabaseBlockAtSelection(editor)) {
toast.error(t('databaseBlock.insertFailed'))
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
}
}
return

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo, useState, Fragment } from 'react'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import {
AlertDialog,
AlertDialogAction,
@@ -26,7 +27,41 @@ import { PropertyValueEditor } from './property-value-editor'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { ChevronDown, ChevronUp, Filter, Trash2 } from 'lucide-react'
import { enUS, fr, faIR } from 'date-fns/locale'
import { ChevronDown, ChevronUp, Filter, Trash2, Sparkles, Brain, Loader2, ArrowUpRight, Link2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { openNotePeek } from '@/lib/note-peek-sync'
const localeMap: Record<string, any> = {
en: enUS,
fr,
fa: faIR
}
function getDateLocale(lang: string) {
return localeMap[lang] || enUS
}
const getBestFallbackKeyword = (title: string): string => {
const STOP_WORDS = new Set([
'dans', 'avec', 'pour', 'plus', 'tout', 'tous', 'cette', 'ceux', 'mais', 'sans',
'faire', 'fait', 'comme', 'sont', 'ont', 'etaient', 'etait', 'sujet', 'fiche', 'page',
'note', 'notes', 'guide', 'guides', 'projet', 'projets', 'test', 'tests', 'demo', 'base',
'bases', 'donnee', 'donnees', 'tableau', 'tableaux', 'quel', 'quels', 'quelle', 'quelles',
'about', 'with', 'from', 'that', 'this', 'your', 'have', 'were', 'was', 'project',
'projects', 'test', 'tests', 'page', 'pages', 'file', 'files', 'some', 'more', 'their',
'them', 'they', 'what', 'which', 'where', 'when', 'how', 'who', 'why'
])
const words = title
.toLowerCase()
.split(/[^\p{L}\d]+/u)
.filter(w => w.length > 3 && !STOP_WORDS.has(w))
if (words.length === 0) return ''
words.sort((a, b) => b.length - a.length)
return words[0]
}
type NotesStructuredTableProps = {
notes: Note[]
@@ -54,6 +89,96 @@ export function NotesStructuredTable({
const [propertyToDelete, setPropertyToDelete] = useState<{ id: string; name: string } | null>(null)
const [deletingProperty, setDeletingProperty] = useState(false)
// Memory Echo states
const { requestAiConsent } = useAiConsent()
const [activeEchoNoteId, setActiveEchoNoteId] = useState<string | null>(null)
const [echoLoading, setEchoLoading] = useState(false)
const [echoError, setEchoError] = useState<string | null>(null)
const [echoConnections, setEchoConnections] = useState<Array<{ noteId: string; title: string; similarity: number; isTextMatch?: boolean }>>([])
const handleFetchRealEcho = async (noteId: string) => {
if (activeEchoNoteId === noteId) {
setActiveEchoNoteId(null)
setEchoConnections([])
setEchoError(null)
return
}
setActiveEchoNoteId(noteId)
setEchoLoading(true)
setEchoConnections([])
setEchoError(null)
try {
// 1. GDPR AI Consent check
const consented = await requestAiConsent()
if (!consented) {
setEchoError("Le consentement pour le traitement par IA est requis pour utiliser la résonance sémantique.")
setEchoLoading(false)
return
}
// 2. Fetch connections
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=5`)
if (res.status === 403) {
setEchoError("Le consentement pour le traitement par IA est requis pour utiliser la résonance sémantique.")
setEchoLoading(false)
return
}
const json = await res.json()
if (json.connections && Array.isArray(json.connections) && json.connections.length > 0) {
setEchoConnections(json.connections)
} else {
// 3. Fallback: Si aucune connexion pré-calculée en DB, on fait une recherche textuelle en direct
const targetNote = notes.find(n => n.id === noteId)
const q = (targetNote?.title || '').trim()
if (!q) {
setEchoError("Cette note n'a pas encore de titre ou de contenu suffisant pour trouver des résonances sémantiques.")
setEchoLoading(false)
return
}
let fallbackRes = await fetch(`/api/notes?search=${encodeURIComponent(q)}&limit=5`)
let fallbackJson = await fallbackRes.json()
let filteredNotes = (fallbackJson.success && Array.isArray(fallbackJson.data))
? fallbackJson.data.filter((n: any) => n.id !== noteId)
: []
if (filteredNotes.length === 0) {
// Fallback to searching significant words if full title matches nothing else
const fallbackWord = getBestFallbackKeyword(q)
if (fallbackWord) {
const resFallback = await fetch(`/api/notes?search=${encodeURIComponent(fallbackWord)}&limit=5`)
const jsonFallback = await resFallback.json()
if (jsonFallback.success && Array.isArray(jsonFallback.data)) {
filteredNotes = jsonFallback.data.filter((n: any) => n.id !== noteId)
}
}
}
if (filteredNotes.length === 0) {
setEchoError(`Aucune note similaire à "${q}" n'a été détectée dans votre espace de travail.`)
} else {
setEchoConnections(filteredNotes.map((n: any, idx: number) => ({
noteId: n.id,
title: n.title || 'Sans titre',
similarity: 0.82 - idx * 0.04,
isTextMatch: true
})))
}
}
} catch (e) {
console.error(e)
setEchoError("Une erreur est survenue lors de la recherche.")
} finally {
setEchoLoading(false)
}
}
const filters: ColumnFilter[] = useMemo(() => {
if (!filterPropId) return []
return [{ propertyId: filterPropId, operator: filterOp, value: filterValue }]
@@ -173,36 +298,134 @@ export function NotesStructuredTable({
{displayed.map((note) => {
const vals = noteValues[note.id] ?? {}
return (
<tr key={note.id} className="hover:bg-foreground/[0.02] transition-colors group">
<td className="px-4 py-2">
<button
type="button"
onClick={() => onOpen(note)}
className="font-memento-serif text-[13px] font-medium text-left truncate max-w-[220px] group-hover:text-brand-accent transition-colors"
>
{getNoteDisplayTitle(note, untitled)}
</button>
</td>
{schema.properties.map((p) => (
<td
key={p.id}
className="px-4 py-2 align-top"
onClick={(e) => e.stopPropagation()}
>
<div className="min-w-[100px] max-w-[180px]">
<PropertyValueEditor
property={p}
value={vals[p.id]}
compact
onChange={(v) => onPropertyChange(note.id, p.id, v)}
/>
<Fragment key={note.id}>
<tr key={note.id} className="hover:bg-foreground/[0.015] transition-colors group">
<td className="px-4 py-2 align-middle">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onOpen(note)}
className="font-memento-serif text-[13px] font-medium text-left truncate max-w-[200px] group-hover:text-brand-accent transition-colors shrink-0"
>
{getNoteDisplayTitle(note, untitled)}
</button>
<button
type="button"
onClick={() => handleFetchRealEcho(note.id)}
className={cn(
"opacity-0 group-hover:opacity-100 p-1 rounded transition-all shrink-0",
activeEchoNoteId === note.id
? "text-purple-400 bg-purple-500/10 opacity-100"
: "text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10"
)}
title="Résonances sémantiques"
>
<Sparkles size={11} className={activeEchoNoteId === note.id ? "animate-pulse" : ""} />
</button>
</div>
</td>
))}
<td className="px-4 py-2 text-[11px] text-muted-foreground whitespace-nowrap">
{formatAbsoluteDateLocalized(new Date(note.updatedAt), language, 'MMM d, yyyy')}
</td>
</tr>
{schema.properties.map((p) => (
<td
key={p.id}
className="px-4 py-2 align-top"
onClick={(e) => e.stopPropagation()}
>
<div className="min-w-[100px] max-w-[180px]">
<PropertyValueEditor
property={p}
value={vals[p.id]}
compact
onChange={(v) => onPropertyChange(note.id, p.id, v)}
/>
</div>
</td>
))}
<td className="px-4 py-2 text-[11px] text-muted-foreground whitespace-nowrap align-middle">
{formatAbsoluteDateLocalized(new Date(note.updatedAt), language, 'MMM d, yyyy', getDateLocale(language))}
</td>
</tr>
{/* Memory Echo collapsible details */}
{activeEchoNoteId === note.id && (
<tr className="bg-purple-500/[0.015]">
<td colSpan={schema.properties.length + 3} className="px-5 py-3.5 border-t border-b border-purple-500/10">
<div className="space-y-3 animate-in slide-in-from-top-1 duration-200">
<div className="flex items-center justify-between text-[11px] font-bold text-foreground/80">
<span className="flex items-center gap-1.5 text-purple-400">
<Brain className="w-3.5 h-3.5 animate-pulse" />
{t('structuredViewBlock.echoPopoverTitle') || 'Résonance Sémantique 🔮'}
</span>
<button
onClick={() => { setActiveEchoNoteId(null); setEchoConnections([]); }}
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
Fermer
</button>
</div>
{echoLoading ? (
<div className="flex items-center gap-2 text-[11px] text-muted-foreground py-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-purple-400" />
<span>{t('structuredViewBlock.echoLoading') || 'Recherche de connexions sémantiques...'}</span>
</div>
) : echoError ? (
<p className="text-[11px] text-muted-foreground/90 font-medium py-1">
{echoError}
</p>
) : echoConnections.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic py-1">
{t('structuredViewBlock.noEchoFound') || 'Aucune résonance sémantique détectée.'}
</p>
) : (
<div className="space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-w-2xl">
{echoConnections.map((conn) => (
<div
key={conn.noteId}
className="flex items-center justify-between p-2.5 rounded-xl bg-card border border-border/50 hover:border-purple-500/40 hover:bg-purple-500/[0.02] text-left text-[11px] transition-all group shadow-sm gap-2"
>
<button
type="button"
onClick={() => openNotePeek({ noteId: conn.noteId })}
className="flex-1 text-left truncate font-semibold text-foreground/80 hover:text-purple-400 transition-colors"
>
{conn.title}
</button>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
window.dispatchEvent(new CustomEvent('memento-insert-citation', {
detail: {
noteId: conn.noteId,
noteTitle: conn.title || 'Sans titre',
excerpt: '',
atEnd: false
}
}))
}}
className="p-1 text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10 rounded transition-colors"
title="Insérer le lien dans l'éditeur"
>
<Link2 className="w-3.5 h-3.5" />
</button>
<span className="text-[9px] text-purple-400 font-bold bg-purple-500/10 px-2 py-0.5 rounded-full flex items-center gap-0.5 border border-purple-500/20">
{conn.isTextMatch ? 'Mot-clé' : `${Math.round((conn.similarity || 0) * 100)}%`}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>

View File

@@ -1,117 +0,0 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
import { useCallback } from 'react'
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { DatabaseBlockEditor } from '@/components/database-block-editor'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import {
createDefaultDatabaseBlockData,
parseDatabaseBlockAttrs,
serializeDatabaseBlockData,
type DatabaseBlockData,
} from '@/lib/editor/database-block-types'
function DatabaseBlockView({ node, updateAttributes, editor }: NodeViewProps) {
const data = parseDatabaseBlockAttrs(node.attrs)
const requestSave = useCallback(() => {
const hostNoteId = editor.storage.liveBlock?.hostNoteId
if (hostNoteId) {
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId: hostNoteId, reason: 'database-block-mutation' },
}))
}
}, [editor])
const handleChange = useCallback((next: DatabaseBlockData) => {
updateAttributes(serializeDatabaseBlockData(next))
requestSave()
}, [requestSave, updateAttributes])
return (
<NodeViewWrapper className="database-block-wrapper" data-drag-handle contentEditable={false}>
<DatabaseBlockEditor data={data} onChange={handleChange} />
</NodeViewWrapper>
)
}
export const DatabaseBlockExtension = Node.create({
name: 'databaseBlock',
group: 'block',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
dbId: {
default: '',
parseHTML: (element) => element.getAttribute('data-db-id') || '',
renderHTML: (attributes) => (attributes.dbId ? { 'data-db-id': attributes.dbId } : {}),
},
dbView: {
default: 'table',
parseHTML: (element) => element.getAttribute('data-db-view') || 'table',
renderHTML: (attributes) => ({ 'data-db-view': attributes.dbView || 'table' }),
},
dbAuthorsJson: {
default: '[]',
parseHTML: (element) => element.getAttribute('data-db-authors') || '[]',
renderHTML: (attributes) => ({ 'data-db-authors': attributes.dbAuthorsJson || '[]' }),
},
dbBooksJson: {
default: '[]',
parseHTML: (element) => element.getAttribute('data-db-books') || '[]',
renderHTML: (attributes) => ({ 'data-db-books': attributes.dbBooksJson || '[]' }),
},
}
},
parseHTML() {
return [{ tag: 'div[data-database-block]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-database-block': 'true' })]
},
addNodeView() {
return ReactNodeViewRenderer(DatabaseBlockView)
},
})
export function insertDatabaseBlockAtSelection(editor: Editor): boolean {
const type = editor.schema.nodes.databaseBlock
if (!type) return false
const attrs = serializeDatabaseBlockData(createDefaultDatabaseBlockData())
const { empty, $from } = editor.state.selection
const parent = $from.parent
if (empty && parent.type.name === 'paragraph' && parent.content.size === 0) {
const pos = $from.before()
return editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
tr.replaceWith(pos, pos + parent.nodeSize, type.create(attrs))
if (dispatch) dispatch(tr)
return true
})
.run()
}
return editor.chain().focus().insertContent({ type: 'databaseBlock', attrs }).run()
}
export function replaceBlockWithDatabase(editor: Editor, blockPos: number, blockNode: PMNode): void {
const type = editor.schema.nodes.databaseBlock
if (!type || blockPos < 0 || !blockNode) return
const attrs = serializeDatabaseBlockData(createDefaultDatabaseBlockData())
const dbNode = type.create(attrs)
editor.view.dispatch(editor.state.tr.replaceWith(blockPos, blockPos + blockNode.nodeSize, dbNode))
editor.commands.focus()
}

View File

@@ -0,0 +1,158 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { StructuredViewBlockEmbed } from '@/components/structured-view-block-embed'
function StructuredViewBlockView(props: NodeViewProps) {
return (
<NodeViewWrapper className="structured-view-block-wrapper" data-drag-handle contentEditable={false}>
<StructuredViewBlockEmbed
notebookId={props.node.attrs.notebookId}
displayMode={props.node.attrs.displayMode}
filterJson={props.node.attrs.filterJson}
isLocal={props.node.attrs.isLocal}
localColumnsJson={props.node.attrs.localColumnsJson}
localRowsJson={props.node.attrs.localRowsJson}
updateAttributes={props.updateAttributes}
editor={props.editor}
/>
</NodeViewWrapper>
)
}
const defaultColumns = [
{ id: 'col-title', name: 'Nom', type: 'text' },
{ id: 'col-done', name: 'Fait', type: 'checkbox' },
{ id: 'col-status', name: 'Statut', type: 'select', options: ['À faire', 'En cours', 'Terminé'] }
]
const defaultRows = [
{ id: 'row-1', values: { 'col-title': '', 'col-done': false, 'col-status': 'À faire' } },
{ id: 'row-2', values: { 'col-title': '', 'col-done': false, 'col-status': 'À faire' } }
]
export const StructuredViewBlockExtension = Node.create({
name: 'structuredViewBlock',
group: 'block',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
notebookId: {
default: null,
parseHTML: (el) => el.getAttribute('data-sv-notebook-id'),
renderHTML: (attrs) => attrs.notebookId
? { 'data-sv-notebook-id': attrs.notebookId }
: {},
},
displayMode: {
default: 'table',
parseHTML: (el) => el.getAttribute('data-sv-mode') || 'table',
renderHTML: (attrs) => ({ 'data-sv-mode': attrs.displayMode || 'table' }),
},
filterJson: {
default: '{}',
parseHTML: (el) => el.getAttribute('data-sv-filter') || '{}',
renderHTML: (attrs) => ({ 'data-sv-filter': attrs.filterJson || '{}' }),
},
isLocal: {
default: true, // Par défaut, on crée une base de données locale autonome
parseHTML: (el) => el.getAttribute('data-sv-is-local') === 'true',
renderHTML: (attrs) => attrs.isLocal
? { 'data-sv-is-local': 'true' }
: { 'data-sv-is-local': 'false' },
},
localColumnsJson: {
default: JSON.stringify(defaultColumns),
parseHTML: (el) => el.getAttribute('data-sv-local-cols') || '[]',
renderHTML: (attrs) => ({ 'data-sv-local-cols': attrs.localColumnsJson || '[]' }),
},
localRowsJson: {
default: JSON.stringify(defaultRows),
parseHTML: (el) => el.getAttribute('data-sv-local-rows') || '[]',
renderHTML: (attrs) => ({ 'data-sv-local-rows': attrs.localRowsJson || '[]' }),
},
}
},
parseHTML() {
return [
{ tag: 'div[data-structured-view-block]' },
{
tag: 'div[data-database-block]',
getAttrs: () => ({
isLocal: true,
localColumnsJson: JSON.stringify(defaultColumns),
localRowsJson: JSON.stringify(defaultRows),
})
}
]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-structured-view-block': 'true' })]
},
addNodeView() {
return ReactNodeViewRenderer(StructuredViewBlockView)
},
})
export function insertStructuredViewBlockAtSelection(editor: Editor, notebookId?: string | null): boolean {
const type = editor.schema.nodes.structuredViewBlock
if (!type) return false
// Par défaut, si la note a un carnet et qu'on n'est pas dans l'inbox, on peut l'insérer liée au carnet.
// Mais si aucun notebookId n'est passé ou si on veut favoriser la base locale autonome Notion-like par défaut :
// On l'insère en base locale autonome par défaut !
const attrs = {
notebookId: null,
displayMode: 'table',
filterJson: '{}',
isLocal: true,
localColumnsJson: JSON.stringify(defaultColumns),
localRowsJson: JSON.stringify(defaultRows)
}
const { empty, $from } = editor.state.selection
const parent = $from.parent
if (empty && parent.type.name === 'paragraph' && parent.content.size === 0) {
const pos = $from.before()
return editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
tr.replaceWith(pos, pos + parent.nodeSize, type.create(attrs))
if (dispatch) dispatch(tr)
return true
})
.run()
}
return editor.chain().focus().insertContent({ type: 'structuredViewBlock', attrs }).run()
}
export function replaceBlockWithStructuredView(
editor: Editor,
blockPos: number,
blockNode: PMNode,
notebookId?: string | null
): void {
const type = editor.schema.nodes.structuredViewBlock
if (!type || blockPos < 0 || !blockNode) return
const attrs = {
notebookId: null,
displayMode: 'table',
filterJson: '{}',
isLocal: true,
localColumnsJson: JSON.stringify(defaultColumns),
localRowsJson: JSON.stringify(defaultRows)
}
const svNode = type.create(attrs)
editor.view.dispatch(editor.state.tr.replaceWith(blockPos, blockPos + blockNode.nodeSize, svNode))
editor.commands.focus()
}