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

@@ -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>