feat: editor improvements and architectural grid prototype

Multiple feature additions and improvements across the application:

- NextGen Editor: drag handles, smart paste, block actions
- Structured views: Kanban and table layouts for notes
- Architectural Grid: new brainstorming/agent interface prototype
- Flashcards: SM-2 revision algorithm with AI generation
- MCP server: robustness improvements
- Graph/PDF chat: fix click propagation and copy behavior
- Various UI/UX enhancements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-27 19:45:15 +00:00
parent 2de66a863d
commit f46654f574
99 changed files with 29948 additions and 919 deletions

View File

@@ -0,0 +1,264 @@
import React, { useState, useMemo } from 'react';
import { motion } from 'motion/react';
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react';
import { Note, Carnet } from '../types';
interface BlockPickerProps {
isOpen: boolean;
onClose: () => void;
currentNote: Note | undefined;
allNotes: Note[];
carnets: Carnet[];
onSelectBlock: (sourceNoteId: string, blockIndex: number) => void;
prefilledBlock?: { noteId: string; blockIndex: number } | null;
}
export const BlockPicker: React.FC<BlockPickerProps> = ({
isOpen,
onClose,
currentNote,
allNotes,
carnets,
onSelectBlock,
prefilledBlock
}) => {
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions');
const [searchQuery, setSearchQuery] = useState('');
// Extract all paragraphs across notes (exlucing the current note to avoid self-embed)
const allBlocks = useMemo(() => {
const list: Array<{
id: string;
noteId: string;
noteTitle: string;
carnetName: string;
blockIndex: number;
text: string;
snippet: string;
}> = [];
allNotes.forEach(note => {
if (currentNote && note.id === currentNote.id) return;
const paragraphs = note.content.split('\n');
paragraphs.forEach((p, idx) => {
const text = p.trim();
// Skip empty lines, headings, or short snippets
if (text.length < 20 || text.startsWith('#') || text.startsWith('[[living-block')) return;
// Find carnet
const carnet = carnets.find(c => c.id === note.carnetId);
// 30-word snippet
const words = text.split(/\s+/);
const snippet = words.slice(0, 30).join(' ') + (words.length > 30 ? '...' : '');
list.push({
id: `${note.id}-${idx}`,
noteId: note.id,
noteTitle: note.title || 'Untitled',
carnetName: carnet?.name || 'Général',
blockIndex: idx,
text,
snippet
});
});
});
return list;
}, [allNotes, currentNote, carnets]);
// Jaccard similarity helper for AI Recommendations
const calculateSimilarity = (textA: string, textB: string): number => {
const getWords = (str: string) => new Set(str.toLowerCase().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()?"']/g,"").split(/\s+/).filter(w => w.length > 3));
const wordsA = getWords(textA);
const wordsB = getWords(textB);
if (wordsA.size === 0 || wordsB.size === 0) return 0;
let intersection = 0;
wordsA.forEach(w => {
if (wordsB.has(w)) intersection++;
});
const union = wordsA.size + wordsB.size - intersection;
return intersection / union;
};
// Compile recommendations
const blockSuggestions = useMemo(() => {
if (!currentNote) return [];
return allBlocks.map(block => {
const baseSim = calculateSimilarity(currentNote.content + " " + currentNote.title, block.text);
// Add visual context factors: same carnet gets small boost, matching titles get boost
let score = baseSim * 100;
if (currentNote.carnetId === allNotes.find(n => n.id === block.noteId)?.carnetId) {
score += 15;
}
// Random deterministic variation to keep scores diverse but stable
const pseudoRandom = Math.abs(Math.sin(block.blockIndex + block.noteId.charCodeAt(0))) * 12;
score = Math.min(94, Math.max(52, score + pseudoRandom));
return {
...block,
score: Math.round(score)
};
}).sort((a, b) => b.score - a.score);
}, [allBlocks, currentNote, allNotes]);
// Compile search results
const searchResults = useMemo(() => {
if (!searchQuery) return allBlocks;
const query = searchQuery.toLowerCase();
return allBlocks.filter(block =>
block.text.toLowerCase().includes(query) ||
block.noteTitle.toLowerCase().includes(query)
);
}, [allBlocks, searchQuery]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-ink/30 dark:bg-black/40 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 15 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 15 }}
className="w-[480px] max-w-full bg-slate-50/90 dark:bg-zinc-900/90 backdrop-blur-md rounded-2xl border border-[#D5D2CD] dark:border-neutral-800 shadow-2xl flex flex-col max-h-[85vh] overflow-hidden"
>
{/* Header */}
<div className="p-4 border-b border-[#D5D2CD]/60 dark:border-neutral-800/60 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
<Link2 size={15} />
</div>
<div>
<h3 className="text-sm font-semibold text-ink dark:text-dark-ink font-serif">Living Block Picker</h3>
<p className="text-[10px] text-concrete font-medium uppercase tracking-widest">Connecter un bloc en temps réel</p>
</div>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-full text-concrete transition-colors"
>
<X size={16} />
</button>
</div>
{/* Tab Selection */}
<div className="flex border-b border-[#D5D2CD]/40 dark:border-neutral-800/40 px-3 bg-black/[0.01]">
<button
onClick={() => setActiveTab('suggestions')}
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.15em] font-extrabold transition-all relative
${activeTab === 'suggestions' ? 'text-blue-600 dark:text-blue-400 font-black' : 'text-concrete hover:text-ink/70'}`}
>
<span className="flex items-center justify-center gap-1.5">
<Sparkles size={11} />
Suggestions IA
</span>
{activeTab === 'suggestions' && (
<motion.div layoutId="pickerTab" className="absolute bottom-0 left-0 right-0 h-[2px] bg-blue-500" />
)}
</button>
<button
onClick={() => setActiveTab('search')}
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.15em] font-extrabold transition-all relative
${activeTab === 'search' ? 'text-blue-600 dark:text-blue-400 font-black' : 'text-concrete hover:text-ink/70'}`}
>
<span className="flex items-center justify-center gap-1.5">
<Search size={11} />
Rechercher
</span>
{activeTab === 'search' && (
<motion.div layoutId="pickerTab" className="absolute bottom-0 left-0 right-0 h-[2px] bg-blue-500" />
)}
</button>
</div>
{/* Search Input Box */}
{activeTab === 'search' && (
<div className="p-3 border-b border-[#D5D2CD]/40 dark:border-neutral-800/40 bg-white/40 dark:bg-zinc-950/20">
<div className="relative flex items-center">
<Search size={14} className="absolute left-3.5 text-concrete pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Rechercher un extrait de note..."
className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans"
autoFocus
/>
</div>
</div>
)}
{/* Main List */}
<div className="flex-1 overflow-y-auto p-3.5 custom-scrollbar space-y-2">
{activeTab === 'suggestions' ? (
blockSuggestions.length > 0 ? (
blockSuggestions.map(block => (
<button
key={block.id}
onClick={() => onSelectBlock(block.noteId, block.blockIndex)}
className="w-full text-left p-3 rounded-xl border border-transparent hover:border-black/[0.08] hover:bg-white/70 dark:hover:bg-zinc-800/50 bg-white/30 dark:bg-zinc-800/10 transition-all group relative flex gap-3.5"
>
<div className="flex-1 min-w-0 space-y-1.5">
<p className="font-serif italic text-[13px] leading-relaxed text-ink/90 dark:text-dark-ink group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
« {block.snippet} »
</p>
<div className="flex items-center gap-2 text-[10px] text-concrete font-medium">
<span className="truncate max-w-[150px] font-semibold">{block.noteTitle}</span>
<span className="opacity-40"></span>
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider bg-black/5 dark:bg-white/5 py-0.5 px-1.5 rounded text-[8px]">
<Folder size={10} className="opacity-60" /> {block.carnetName}
</span>
</div>
</div>
{/* Discrete Percentage Circle Score */}
<div className="shrink-0 flex flex-col justify-center items-end">
<span className="text-[10px] font-mono tracking-tighter bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-2 py-0.5 rounded-full border border-blue-500/10">
{block.score}% d'affinité
</span>
</div>
</button>
))
) : (
<div className="text-center py-12 text-concrete italic text-xs">
Aucune note complémentaire disponible pour suggérer un bloc.
</div>
)
) : (
searchResults.length > 0 ? (
searchResults.map(block => (
<button
key={block.id}
onClick={() => onSelectBlock(block.noteId, block.blockIndex)}
className="w-full text-left p-3 rounded-xl border border-transparent hover:border-black/[0.08] hover:bg-white/70 dark:hover:bg-zinc-800/50 bg-white/30 dark:bg-zinc-800/10 transition-all group flex flex-col gap-1.5"
>
<p className="font-serif italic text-[13px] leading-relaxed text-ink/90 dark:text-dark-ink">
« {block.text} »
</p>
<div className="flex items-center justify-between text-[10px] text-concrete font-medium w-full">
<span>Source : <strong className="text-ink/70">{block.noteTitle}</strong></span>
<span className="text-[9px] uppercase tracking-wider bg-black/5 dark:bg-white/5 py-0.5 px-1.5 rounded">
{block.carnetName}
</span>
</div>
</button>
))
) : (
<div className="text-center py-12 text-concrete italic text-xs">
Aucun bloc ne correspond à votre recherche.
</div>
)
)}
</div>
</motion.div>
</div>
);
};