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>
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|