feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
This commit is contained in:
@@ -10,6 +10,9 @@ import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import type { BlockSuggestion } from '@/components/block-picker'
|
||||
import { toast } from 'sonner'
|
||||
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
|
||||
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||
import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity'
|
||||
|
||||
interface ConnectionData {
|
||||
noteId: string
|
||||
@@ -40,16 +43,16 @@ interface PreviewTarget {
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function excerpt(text: string, max = 150): string {
|
||||
const plain = stripHtml(text)
|
||||
const plain = stripHtmlToPlainText(text)
|
||||
if (plain.length <= max) return plain
|
||||
return `${plain.slice(0, max).trim()}…`
|
||||
}
|
||||
|
||||
function isRtlText(text: string): boolean {
|
||||
return detectTextDirection(text) === 'rtl'
|
||||
}
|
||||
|
||||
async function resolveBlockForEmbed(sourceNoteId: string, hint: string): Promise<{ block: BlockSuggestion; mode: 'live' | 'citation' } | null> {
|
||||
const params = new URLSearchParams({ noteId: sourceNoteId, hint })
|
||||
const res = await fetch(`/api/blocks/resolve?${params}`)
|
||||
@@ -155,7 +158,7 @@ export function MemoryEchoSection({
|
||||
useEffect(() => {
|
||||
if (isLoading || connections.length === 0) return
|
||||
const top = connections[0]
|
||||
if (!top || top.similarity < 0.75) return
|
||||
if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return
|
||||
const key = `memory-echo-scroll-${noteId}`
|
||||
if (sessionStorage.getItem(key)) return
|
||||
sessionStorage.setItem(key, '1')
|
||||
@@ -167,7 +170,7 @@ export function MemoryEchoSection({
|
||||
const handleEmbed = useCallback(async (conn: ConnectionData) => {
|
||||
setEmbeddingId(conn.noteId)
|
||||
try {
|
||||
const hint = excerpt(stripHtml(conn.content), 300)
|
||||
const hint = (conn.content || '').trim().slice(0, 12000)
|
||||
const resolved = await resolveBlockForEmbed(conn.noteId, hint)
|
||||
const noteTitle = conn.title || t('memoryEcho.comparison.untitled')
|
||||
|
||||
@@ -200,7 +203,7 @@ export function MemoryEchoSection({
|
||||
return
|
||||
}
|
||||
|
||||
const citationText = stripHtml(
|
||||
const citationText = stripHtmlToPlainText(
|
||||
resolved?.block.content || conn.content || hint
|
||||
).slice(0, 1200)
|
||||
|
||||
@@ -213,7 +216,9 @@ export function MemoryEchoSection({
|
||||
|
||||
if (editorCtx.state.isMarkdown) {
|
||||
const quoted = citationText.split('\n').map(line => `> ${line}`).join('\n')
|
||||
const mdCitation = `\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n`
|
||||
const mdCitation = isRtlText(citationText)
|
||||
? `\n\n<div dir="rtl" lang="fa">\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n\n</div>\n`
|
||||
: `\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n`
|
||||
editorCtx.actions.setContent(editorCtx.state.content + mdCitation)
|
||||
toast.success(t('memoryEcho.editorSection.citationSuccess'))
|
||||
return
|
||||
@@ -326,7 +331,14 @@ export function MemoryEchoSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<blockquote className="border-l-2 border-indigo-500/20 pl-3 font-serif italic text-sm leading-relaxed text-foreground/85">
|
||||
<blockquote
|
||||
className={cn(
|
||||
'border-indigo-500/20 pl-3 font-serif italic text-sm leading-relaxed text-foreground/85',
|
||||
isRtlText(topConnection.content) ? 'border-r-2 border-l-0 pr-3 pl-0 text-right' : 'border-l-2',
|
||||
)}
|
||||
dir={isRtlText(topConnection.content) ? 'rtl' : undefined}
|
||||
lang={isRtlText(topConnection.content) ? 'fa' : undefined}
|
||||
>
|
||||
« {excerpt(topConnection.content)} »
|
||||
</blockquote>
|
||||
|
||||
|
||||
@@ -3,69 +3,25 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
// Force to group nodes by cluster
|
||||
function forceCluster() {
|
||||
let nodes: any[] = []
|
||||
let clusters: Map<string | number, { x: number; y: number }> = new Map()
|
||||
|
||||
function force(alpha: number) {
|
||||
// Calculate cluster centers
|
||||
clusters.clear()
|
||||
for (const node of nodes) {
|
||||
const clusterId = node.clusterId
|
||||
if (!clusters.has(clusterId)) {
|
||||
clusters.set(clusterId, { x: 0, y: 0, count: 0 })
|
||||
}
|
||||
const center = clusters.get(clusterId)!
|
||||
center.x += node.x
|
||||
center.y += node.y
|
||||
center.count = (center.count || 0) + 1
|
||||
}
|
||||
|
||||
// Average positions
|
||||
for (const [clusterId, center] of clusters.entries()) {
|
||||
center.x /= center.count || 1
|
||||
center.y /= center.count || 1
|
||||
}
|
||||
|
||||
// Move nodes toward their cluster center
|
||||
for (const node of nodes) {
|
||||
const clusterCenter = clusters.get(node.clusterId)
|
||||
if (clusterCenter && clusterCenter.count > 1) {
|
||||
const targetX = clusterCenter.x
|
||||
const targetY = clusterCenter.y
|
||||
node.vx += (targetX - node.x) * alpha * 0.3
|
||||
node.vy += (targetY - node.y) * alpha * 0.3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
force.initialize = function(newNodes: any[]) {
|
||||
nodes = newNodes
|
||||
return force
|
||||
}
|
||||
|
||||
return force
|
||||
}
|
||||
|
||||
interface Note {
|
||||
id: string
|
||||
title: string | null
|
||||
clusterId?: string | number
|
||||
isCentral?: boolean
|
||||
}
|
||||
|
||||
interface NoteCluster {
|
||||
id: string | number
|
||||
name: string
|
||||
name?: string
|
||||
noteIds: string[]
|
||||
color: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface BridgeNote {
|
||||
noteId: string
|
||||
bridgeScore: number
|
||||
clustersConnected?: (string | number)[]
|
||||
connectedClusterIds?: (string | number)[]
|
||||
clusterNames?: string[]
|
||||
}
|
||||
|
||||
interface NetworkGraphProps {
|
||||
@@ -73,13 +29,17 @@ interface NetworkGraphProps {
|
||||
clusters: NoteCluster[]
|
||||
bridgeNotes: BridgeNote[]
|
||||
onNoteSelect: (id: string) => void
|
||||
selectedClusterId?: string | null
|
||||
onClusterSelect?: (id: string | null) => void
|
||||
}
|
||||
|
||||
export function NetworkGraph({
|
||||
notes,
|
||||
clusters,
|
||||
bridgeNotes,
|
||||
onNoteSelect
|
||||
onNoteSelect,
|
||||
selectedClusterId = null,
|
||||
onClusterSelect
|
||||
}: NetworkGraphProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -103,8 +63,10 @@ export function NetworkGraph({
|
||||
|
||||
svg.call(zoom as any)
|
||||
|
||||
// Filter notes with cluster assignments (properly check for undefined/null)
|
||||
const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null)
|
||||
// Filter notes with cluster assignments
|
||||
const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null && String(n.clusterId) !== '-1')
|
||||
|
||||
if (visibleNotes.length === 0) return
|
||||
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string
|
||||
@@ -112,6 +74,7 @@ export function NetworkGraph({
|
||||
clusterId: string | number
|
||||
color: string
|
||||
isBridge: boolean
|
||||
isCentral: boolean
|
||||
radius: number
|
||||
}
|
||||
|
||||
@@ -119,80 +82,189 @@ export function NetworkGraph({
|
||||
source: string
|
||||
target: string
|
||||
strength: number
|
||||
type?: 'inner' | 'bridge'
|
||||
}
|
||||
|
||||
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId))
|
||||
|
||||
// 1. Initialisation des nœuds avec rôles et diamètres distincts
|
||||
const nodes: D3Node[] = visibleNotes.map(n => {
|
||||
const cluster = clusters.find(c => c.id === String(n.clusterId))
|
||||
const cluster = clusters.find(c => String(c.id) === String(n.clusterId))
|
||||
const isBridge = bridgeSet.has(n.id)
|
||||
const isCentral = !!n.isCentral
|
||||
|
||||
// Hiérarchie de tailles premium
|
||||
let radius = 6
|
||||
if (isCentral) radius = 13
|
||||
else if (isBridge) radius = 10
|
||||
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
clusterId: n.clusterId!,
|
||||
color: cluster?.color || '#cbd5e1',
|
||||
isBridge,
|
||||
radius: isBridge ? 12 : 8
|
||||
isCentral,
|
||||
radius
|
||||
}
|
||||
})
|
||||
|
||||
const links: D3Link[] = []
|
||||
// Connect notes within the same cluster
|
||||
for (let i = 0; i < visibleNotes.length; i++) {
|
||||
for (let j = i + 1; j < visibleNotes.length; j++) {
|
||||
const ni = visibleNotes[i]
|
||||
const nj = visibleNotes[j]
|
||||
|
||||
if (ni.clusterId === nj.clusterId) {
|
||||
links.push({ source: ni.id, target: nj.id, strength: 0.5 })
|
||||
}
|
||||
// Groupement des nœuds par cluster
|
||||
const clusterGroups = new Map<string | number, D3Node[]>()
|
||||
nodes.forEach(node => {
|
||||
const cid = node.clusterId
|
||||
if (!clusterGroups.has(cid)) {
|
||||
clusterGroups.set(cid, [])
|
||||
}
|
||||
}
|
||||
clusterGroups.get(cid)!.push(node)
|
||||
})
|
||||
|
||||
const links: D3Link[] = []
|
||||
|
||||
// 2. Création de la structure en étoile (Star-Network) par cluster
|
||||
clusterGroups.forEach((groupNodes, cid) => {
|
||||
if (groupNodes.length <= 1) return
|
||||
|
||||
// Trouver le nœud central existant, ou en désigner un par défaut (le premier)
|
||||
let hub = groupNodes.find(n => n.isCentral)
|
||||
if (!hub) {
|
||||
hub = groupNodes[0]
|
||||
hub.isCentral = true
|
||||
hub.radius = 13 // Augmenter sa taille pour la hiérarchie visuelle
|
||||
}
|
||||
|
||||
// Relier chaque feuille du cluster UNIQUEMENT à son nœud central (Hub)
|
||||
groupNodes.forEach(node => {
|
||||
if (node.id !== hub!.id) {
|
||||
links.push({
|
||||
source: node.id,
|
||||
target: hub!.id,
|
||||
strength: 0.5,
|
||||
type: 'inner'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 3. Liaison de ponts dorées reliant les nœuds centraux (Hubs) (Garde-fou D3 contre les nœuds manquants)
|
||||
const nodeSet = new Set(nodes.map(n => n.id))
|
||||
|
||||
bridgeNotes.forEach(b => {
|
||||
if (!b.clustersConnected) return
|
||||
if (!nodeSet.has(b.noteId)) return // Évite d'ajouter un lien si la note-pont n'est pas dans les nœuds affichés
|
||||
|
||||
b.clustersConnected.forEach(cid => {
|
||||
const targetNodes = clusterGroups.get(cid) || []
|
||||
if (targetNodes.length > 0) {
|
||||
const targetHub = targetNodes.find(n => n.isCentral) || targetNodes[0]
|
||||
if (nodeSet.has(targetHub.id)) {
|
||||
links.push({
|
||||
source: b.noteId,
|
||||
target: targetHub.id,
|
||||
strength: 0.15,
|
||||
type: 'bridge'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 4. Pré-positionnement géométrique des Hubs en cercle pour éviter toute superposition initiale
|
||||
const uniqueClusterIds = Array.from(clusterGroups.keys())
|
||||
const numClusters = uniqueClusterIds.length
|
||||
const radiusCircle = Math.min(width, height) * 0.28 // Rayon de répartition
|
||||
|
||||
uniqueClusterIds.forEach((cid, index) => {
|
||||
const angle = (index * 2 * Math.PI) / numClusters
|
||||
const hubX = width / 2 + radiusCircle * Math.cos(angle)
|
||||
const hubY = height / 2 + radiusCircle * Math.sin(angle)
|
||||
|
||||
const groupNodes = clusterGroups.get(cid) || []
|
||||
const hub = groupNodes.find(n => n.isCentral) || groupNodes[0]
|
||||
|
||||
if (hub) {
|
||||
hub.x = hubX
|
||||
hub.y = hubY
|
||||
}
|
||||
|
||||
// Positionner les feuilles autour de leur propre hub
|
||||
groupNodes.forEach(node => {
|
||||
if (node.id !== hub?.id) {
|
||||
const leafAngle = Math.random() * 2 * Math.PI
|
||||
const leafDist = 25 + Math.random() * 20
|
||||
node.x = hubX + leafDist * Math.cos(leafAngle)
|
||||
node.y = hubY + leafDist * Math.sin(leafAngle)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// D3 simulation — Paramètres de physique ultra-stables, centrés et étalés comme des galaxies
|
||||
const simulation = d3.forceSimulation<D3Node>(nodes)
|
||||
.force('link', d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(50))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('link', d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(d => d.type === 'bridge' ? 140 : 35)) // Feuilles proches du Hub (35px) pour des constellations compactes et lisibles
|
||||
.force('charge', d3.forceManyBody().strength(d => (d as D3Node).isCentral ? -500 : -80)) // Répulsion équilibrée pour éviter de projeter les Hubs contre les bords de l'écran
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide<D3Node>().radius(d => d.radius + 15))
|
||||
.force('cluster', forceCluster())
|
||||
.force('x', d3.forceX(width / 2).strength(0.12)) // Recentrage X renforcé pour l'équilibre central
|
||||
.force('y', d3.forceY(height / 2).strength(0.12)) // Recentrage Y renforcé
|
||||
.force('collision', d3.forceCollide<D3Node>().radius(d => d.radius + 14)) // Collision ajustée pour préserver la compacité
|
||||
|
||||
// Links
|
||||
|
||||
// Liens avec couleur et opacité contextuelle
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('stroke', '#e2e8f0')
|
||||
.attr('stroke-opacity', 0.6)
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke', (d: any) => d.type === 'bridge' ? '#E2B13C' : '#cbd5e1')
|
||||
.attr('stroke-dasharray', (d: any) => d.type === 'bridge' ? '4,4' : 'none')
|
||||
.attr('stroke-opacity', (d: any) => {
|
||||
if (d.type === 'bridge') return selectedClusterId ? 0.15 : 0.6
|
||||
if (!selectedClusterId) return 0.4
|
||||
const sId = typeof d.source === 'string' ? d.source : (d.source as any).id
|
||||
const tId = typeof d.target === 'string' ? d.target : (d.target as any).id
|
||||
const sourceNode = nodes.find(n => n.id === sId)
|
||||
const targetNode = nodes.find(n => n.id === tId)
|
||||
const sCluster = String(sourceNode?.clusterId)
|
||||
const tCluster = String(targetNode?.clusterId)
|
||||
return sCluster === selectedClusterId && tCluster === selectedClusterId ? 0.7 : 0.04
|
||||
})
|
||||
.attr('stroke-width', (d: any) => d.type === 'bridge' ? 1.5 : 1)
|
||||
|
||||
// Nodes
|
||||
// Nœuds avec opacité focus
|
||||
const node = g.append('g')
|
||||
.selectAll('.node')
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node cursor-pointer')
|
||||
.attr('opacity', d => {
|
||||
if (!selectedClusterId) return 1
|
||||
return String(d.clusterId) === selectedClusterId ? 1 : 0.15
|
||||
})
|
||||
.on('click', (event, d) => onNoteSelect(d.id))
|
||||
.call(d3.drag<SVGGElement, D3Node>()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended) as any)
|
||||
|
||||
// Cercles avec tailles hiérarchiques et halos
|
||||
node.append('circle')
|
||||
.attr('r', d => d.radius)
|
||||
.attr('fill', d => d.color)
|
||||
.attr('stroke', d => d.isBridge ? '#D4AF37' : '#fff')
|
||||
.attr('stroke-width', d => d.isBridge ? 3 : 2)
|
||||
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))' : 'none')
|
||||
.attr('stroke', d => d.isCentral ? 'rgba(255,255,255,0.9)' : d.isBridge ? '#D4AF37' : '#fff')
|
||||
.attr('stroke-width', d => d.isCentral ? 3 : d.isBridge ? 2.5 : 1.5)
|
||||
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 6px rgba(212, 175, 55, 0.5))' : 'none')
|
||||
|
||||
// Labels de textes ultra-lisibles claire/sombre sans chevauchement
|
||||
node.append('text')
|
||||
.attr('dy', d => d.radius + 14)
|
||||
.attr('dy', d => d.radius + 13)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', 'text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none')
|
||||
.attr('font-size', d => d.isCentral ? '10px' : '9px')
|
||||
.attr('font-weight', d => d.isCentral ? '700' : '500')
|
||||
.attr('fill', '#4b5563')
|
||||
.attr('class', 'dark:fill-zinc-300 font-sans pointer-events-none')
|
||||
.text(d => {
|
||||
const title = d.title || 'Untitled'
|
||||
return title.length > 20 ? title.substring(0, 20) + '...' : title
|
||||
const title = d.title || 'Sans titre'
|
||||
return title.length > 20 ? title.substring(0, 18) + '...' : title
|
||||
})
|
||||
|
||||
simulation.on('tick', () => {
|
||||
@@ -203,9 +275,46 @@ export function NetworkGraph({
|
||||
.attr('y2', d => (d.target as any).y)
|
||||
|
||||
node
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
.attr('transform', d => {
|
||||
// Bounding box rigide : maintient à 100% les clusters sur l'écran
|
||||
const padding = 35
|
||||
d.x = Math.max(padding, Math.min(width - padding, d.x || width / 2))
|
||||
d.y = Math.max(padding, Math.min(height - padding, d.y || height / 2))
|
||||
return `translate(${d.x},${d.y})`
|
||||
})
|
||||
})
|
||||
|
||||
// Zoom automatique sur le cluster sélectionné (800ms)
|
||||
if (selectedClusterId && width && height) {
|
||||
const clusterNodes = nodes.filter(n => String(n.clusterId) === selectedClusterId)
|
||||
if (clusterNodes.length > 0) {
|
||||
// Avancer la simulation pour obtenir des coordonnées stabilisées
|
||||
for (let i = 0; i < 60; ++i) simulation.tick()
|
||||
|
||||
const xCoords = clusterNodes.map(cn => cn.x).filter((x): x is number => x !== undefined)
|
||||
const yCoords = clusterNodes.map(cn => cn.y).filter((y): y is number => y !== undefined)
|
||||
|
||||
if (xCoords.length > 0 && yCoords.length > 0) {
|
||||
const avgX = d3.mean(xCoords) || width / 2
|
||||
const avgY = d3.mean(yCoords) || height / 2
|
||||
|
||||
svg.transition()
|
||||
.duration(800)
|
||||
.call(
|
||||
zoom.transform,
|
||||
d3.zoomIdentity
|
||||
.translate(width / 2, height / 2)
|
||||
.scale(1.3)
|
||||
.translate(-avgX, -avgY)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (!selectedClusterId) {
|
||||
svg.transition()
|
||||
.duration(800)
|
||||
.call(zoom.transform, d3.zoomIdentity)
|
||||
}
|
||||
|
||||
function dragstarted(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
@@ -226,17 +335,37 @@ export function NetworkGraph({
|
||||
return () => {
|
||||
simulation.stop()
|
||||
}
|
||||
}, [notes, clusters, bridgeNotes, onNoteSelect])
|
||||
}, [notes, clusters, bridgeNotes, onNoteSelect, selectedClusterId])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
||||
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-3 max-w-[300px]">
|
||||
{clusters.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-white/80 dark:bg-white/5 backdrop-blur-sm border border-border rounded-full shadow-sm">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Pastilles de cluster — cliquables pour activer le focus */}
|
||||
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-2 max-w-[90%]">
|
||||
{clusters.map(c => {
|
||||
const isSelected = String(c.id) === selectedClusterId
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border shadow-sm transition-all text-[9px] font-bold uppercase tracking-wider ${
|
||||
isSelected
|
||||
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
|
||||
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span>{c.name ?? String(c.id)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{selectedClusterId && (
|
||||
<button
|
||||
onClick={() => onClusterSelect?.(null)}
|
||||
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 dark:hover:bg-rose-950/30 transition-all shadow-sm"
|
||||
>
|
||||
Réinitialiser focus
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { faIR } from 'date-fns/locale/fa-IR'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon, Network, Copy } from 'lucide-react'
|
||||
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon, Network, Copy, ExternalLink } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
@@ -73,6 +73,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
const locale = getLocale(language)
|
||||
|
||||
const displayNoteType = useMemo(() => {
|
||||
if (note.sourceUrl) return t('notes.noteTypes.clip')
|
||||
const map: Record<string, string> = {
|
||||
richtext: t('notes.noteTypes.richtext'),
|
||||
markdown: t('notes.noteTypes.markdown'),
|
||||
@@ -80,7 +81,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
checklist: t('notes.noteTypes.checklist'),
|
||||
}
|
||||
return map[note.type] || note.type
|
||||
}, [t, note.type])
|
||||
}, [t, note.type, note.sourceUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'versions' && historyEnabled) {
|
||||
@@ -227,6 +228,23 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{note.sourceUrl && (
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.sourceWebLabel')}</p>
|
||||
<a
|
||||
href={note.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-brand-accent hover:underline break-all"
|
||||
>
|
||||
{note.sourceUrl}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdAt && (
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<Calendar className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
|
||||
@@ -107,6 +107,7 @@ export function NoteContentArea() {
|
||||
className="min-h-[280px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
sourceUrl={note.sourceUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -121,6 +122,7 @@ export function NoteContentArea() {
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
sourceUrl={note.sourceUrl}
|
||||
/>
|
||||
<GhostTags
|
||||
suggestions={state.filteredSuggestions}
|
||||
|
||||
@@ -7,12 +7,17 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { resolveTitleDirection, resolveTitleLang } from '@/lib/clip/rtl-content'
|
||||
|
||||
export function NoteTitleBlock() {
|
||||
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
|
||||
const { note, state, actions, readOnly, fullPage } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
|
||||
const titleDir = resolveTitleDirection(state.title || '', note.sourceUrl)
|
||||
const titleLang = resolveTitleLang(state.title || '', note.sourceUrl)
|
||||
const titleIsRtl = titleDir === 'rtl'
|
||||
|
||||
if (fullPage) {
|
||||
// Adaptive font size: short = big editorial, long = smaller but still premium
|
||||
const titleLen = (state.title || '').length
|
||||
@@ -28,7 +33,8 @@ export function NoteTitleBlock() {
|
||||
{/* Title — auto-resizing textarea, adaptive size */}
|
||||
<div className="group relative">
|
||||
<textarea
|
||||
dir="auto"
|
||||
dir={titleDir}
|
||||
lang={titleLang}
|
||||
rows={1}
|
||||
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
|
||||
value={state.title}
|
||||
@@ -40,11 +46,14 @@ export function NoteTitleBlock() {
|
||||
}}
|
||||
disabled={readOnly}
|
||||
className={cn(
|
||||
'w-full font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground',
|
||||
'w-full font-bold border-0 outline-none px-0 bg-transparent text-foreground',
|
||||
titleIsRtl
|
||||
? 'font-[family-name:var(--font-sans)] text-right'
|
||||
: 'font-memento-serif text-left',
|
||||
'leading-[1.15] tracking-tight',
|
||||
'placeholder:text-foreground/20 resize-none overflow-hidden',
|
||||
titleSizeClass,
|
||||
!readOnly && 'pr-12'
|
||||
!readOnly && (titleIsRtl ? 'ps-12' : 'pe-12')
|
||||
)}
|
||||
style={{ height: 'auto' }}
|
||||
ref={(el) => {
|
||||
@@ -92,7 +101,10 @@ export function NoteTitleBlock() {
|
||||
} finally { actions.setIsProcessingAI(false) }
|
||||
}}
|
||||
disabled={state.isProcessingAI}
|
||||
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
|
||||
className={cn(
|
||||
'absolute top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5',
|
||||
titleIsRtl ? 'left-0' : 'right-0',
|
||||
)}
|
||||
title={t('ai.generateTitlesTooltip')}
|
||||
>
|
||||
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
|
||||
@@ -117,14 +129,16 @@ export function NoteTitleBlock() {
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
dir="auto"
|
||||
dir={titleDir}
|
||||
lang={titleLang}
|
||||
placeholder={t('notes.titlePlaceholder')}
|
||||
value={state.title}
|
||||
onChange={(e) => actions.setTitle(e.target.value)}
|
||||
disabled={readOnly}
|
||||
className={cn(
|
||||
"w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-10",
|
||||
readOnly && "cursor-default"
|
||||
'w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent',
|
||||
titleIsRtl ? 'text-right font-[family-name:var(--font-sans)] ps-10' : 'text-left pe-10',
|
||||
readOnly && 'cursor-default',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { openNotePath } from '@/lib/navigation/open-note'
|
||||
import {
|
||||
Loader2,
|
||||
Network,
|
||||
@@ -50,8 +52,19 @@ interface NotePreview {
|
||||
|
||||
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
|
||||
|
||||
export function NoteGraphView() {
|
||||
type EdgeTypeKey = 'explicit_link' | 'semantic_echo' | 'title_mention' | 'shared_label' | 'jaccard'
|
||||
|
||||
const DEFAULT_EDGE_FILTERS: Record<EdgeTypeKey, boolean> = {
|
||||
explicit_link: true,
|
||||
semantic_echo: true,
|
||||
title_mention: true,
|
||||
shared_label: true,
|
||||
jaccard: false,
|
||||
}
|
||||
|
||||
export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
||||
const router = useRouter()
|
||||
const { notebooks } = useNotebooks()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const graphRef = useRef<any>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
|
||||
@@ -63,6 +76,10 @@ export function NoteGraphView() {
|
||||
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
|
||||
const [edgeFilters, setEdgeFilters] = useState(DEFAULT_EDGE_FILTERS)
|
||||
const [semanticMinWeight, setSemanticMinWeight] = useState(0.45)
|
||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null)
|
||||
const [controlsOpen, setControlsOpen] = useState(!embedded)
|
||||
|
||||
const { t } = useLanguage()
|
||||
|
||||
@@ -150,26 +167,48 @@ export function NoteGraphView() {
|
||||
const colorMap = useMemo(() => {
|
||||
if (!rawData) return new Map<string | null, string>()
|
||||
const map = new Map<string | null, string>()
|
||||
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))]
|
||||
ids.forEach((id, i) => map.set(id, PALETTE[i % PALETTE.length]))
|
||||
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))] as string[]
|
||||
ids.forEach((id, i) => {
|
||||
const nb = notebooks.find(n => n.id === id)
|
||||
map.set(id, nb?.color || PALETTE[i % PALETTE.length])
|
||||
})
|
||||
return map
|
||||
}, [rawData])
|
||||
}, [rawData, notebooks])
|
||||
|
||||
const neighborIds = useMemo(() => {
|
||||
if (!focusNodeId || !rawData) return null
|
||||
const ids = new Set<string>([focusNodeId])
|
||||
for (const edge of rawData.edges) {
|
||||
if (edge.source === focusNodeId) ids.add(edge.target)
|
||||
if (edge.target === focusNodeId) ids.add(edge.source)
|
||||
}
|
||||
return ids
|
||||
}, [focusNodeId, rawData])
|
||||
|
||||
// ─── Graph data ───────────────────────────────────────────────────────────
|
||||
const graphData = useMemo(() => {
|
||||
if (!rawData) return { nodes: [], links: [] }
|
||||
|
||||
// Filter by notebook
|
||||
let filtered = selectedNotebookId
|
||||
? rawData.nodes.filter(n => n.notebookId === selectedNotebookId)
|
||||
: rawData.nodes
|
||||
|
||||
// Filter by text search
|
||||
if (neighborIds) {
|
||||
filtered = filtered.filter(n => neighborIds.has(n.id))
|
||||
}
|
||||
|
||||
filtered = searchFilter.trim()
|
||||
? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
: filtered
|
||||
|
||||
const filteredIds = new Set(filtered.map(n => n.id))
|
||||
const visibleEdges = rawData.edges.filter(e => {
|
||||
const type = e.type as EdgeTypeKey
|
||||
if (!(type in edgeFilters) || !edgeFilters[type]) return false
|
||||
if (type === 'semantic_echo' && e.weight < semanticMinWeight) return false
|
||||
return filteredIds.has(e.source) && filteredIds.has(e.target)
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: filtered.map(n => ({
|
||||
id: n.id,
|
||||
@@ -179,39 +218,37 @@ export function NoteGraphView() {
|
||||
notebookId: n.notebookId,
|
||||
degree: n.degree,
|
||||
})),
|
||||
links: rawData.edges
|
||||
.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target))
|
||||
.map(e => {
|
||||
let color = '#e2e8f0'
|
||||
let width = 0.6
|
||||
let dash = false
|
||||
links: visibleEdges.map(e => {
|
||||
let color = '#cbd5e1'
|
||||
let width = 2.5
|
||||
let dash = false
|
||||
|
||||
if (e.type === 'explicit_link') {
|
||||
color = '#10b981' // Green
|
||||
width = 2.2
|
||||
} else if (e.type === 'semantic_echo') {
|
||||
color = '#a78bfa' // Purple
|
||||
width = 1.8
|
||||
dash = true
|
||||
} else if (e.type === 'title_mention') {
|
||||
color = '#f59e0b' // Amber/Orange
|
||||
width = 1.6
|
||||
} else if (e.type === 'shared_label') {
|
||||
color = '#3b82f6' // Blue
|
||||
width = 1.2
|
||||
}
|
||||
if (e.type === 'explicit_link') {
|
||||
color = '#10b981'
|
||||
width = 4.5
|
||||
} else if (e.type === 'semantic_echo') {
|
||||
color = '#8b5cf6'
|
||||
width = 3.5
|
||||
dash = true
|
||||
} else if (e.type === 'title_mention') {
|
||||
color = '#f59e0b'
|
||||
width = 3.2
|
||||
} else if (e.type === 'shared_label') {
|
||||
color = '#3b82f6'
|
||||
width = 2.8
|
||||
}
|
||||
|
||||
return {
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
color,
|
||||
width,
|
||||
dash,
|
||||
type: e.type,
|
||||
}
|
||||
}),
|
||||
return {
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
color,
|
||||
width,
|
||||
dash,
|
||||
type: e.type,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}, [rawData, searchFilter, colorMap, selectedNotebookId])
|
||||
}, [rawData, searchFilter, colorMap, selectedNotebookId, edgeFilters, semanticMinWeight, neighborIds])
|
||||
|
||||
const selectedNotebookName = useMemo(() => {
|
||||
if (!selectedNode || !rawData) return null
|
||||
@@ -226,20 +263,39 @@ export function NoteGraphView() {
|
||||
const now = Date.now()
|
||||
const last = lastClickRef.current
|
||||
if (last && last.id === node.id && now - last.time < 350) {
|
||||
// Double-click → zoom
|
||||
lastClickRef.current = null
|
||||
graphRef.current?.centerAt(node.x, node.y, 600)
|
||||
graphRef.current?.zoom(3, 600)
|
||||
router.push(openNotePath(node.id))
|
||||
return
|
||||
}
|
||||
lastClickRef.current = { id: node.id, time: now }
|
||||
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
|
||||
}, [rawData])
|
||||
}, [rawData, router])
|
||||
|
||||
const handleZoomToFit = useCallback(() => {
|
||||
graphRef.current?.zoomToFit(400, 50)
|
||||
}, [])
|
||||
|
||||
const toggleEdgeFilter = useCallback((key: EdgeTypeKey) => {
|
||||
setEdgeFilters(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
|
||||
// Zoom vers le premier nœud correspondant à la recherche
|
||||
useEffect(() => {
|
||||
if (!searchFilter.trim() || graphData.nodes.length === 0) return
|
||||
const timer = window.setTimeout(() => {
|
||||
const fg = graphRef.current
|
||||
if (!fg) return
|
||||
const match = fg.graphData()?.nodes?.find((n: { id: string; name?: string }) =>
|
||||
(n.name ?? '').toLowerCase().includes(searchFilter.toLowerCase())
|
||||
)
|
||||
if (match?.x != null && match?.y != null) {
|
||||
fg.centerAt(match.x, match.y, 500)
|
||||
fg.zoom(2.2, 500)
|
||||
}
|
||||
}, 600)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [searchFilter, graphData.nodes.length])
|
||||
|
||||
|
||||
|
||||
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
|
||||
@@ -296,14 +352,17 @@ export function NoteGraphView() {
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#FAFAF9]">
|
||||
{/* Header */}
|
||||
<div className={`flex flex-col h-full ${embedded ? 'bg-transparent' : 'bg-[#FAFAF9]'}`}>
|
||||
{!embedded && (
|
||||
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
|
||||
<Network size={16} className="text-indigo-500" />
|
||||
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
|
||||
{rawData && (
|
||||
<span className="text-[10px] text-concrete/50 font-medium">
|
||||
{t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })}
|
||||
{graphData.links.length !== rawData.edges.length && (
|
||||
<> · {t('graphView.visibleConnections', { count: graphData.links.length })}</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
@@ -323,6 +382,7 @@ export function NoteGraphView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas */}
|
||||
<div ref={containerRef} className="flex-1 relative overflow-hidden">
|
||||
@@ -349,7 +409,10 @@ export function NoteGraphView() {
|
||||
nodeLabel="name"
|
||||
linkColor="color"
|
||||
linkWidth="width"
|
||||
linkLineDash={(link: any) => link.dash ? [4, 3] : null}
|
||||
linkOpacity={0.92}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleWidth={2.5}
|
||||
linkLineDash={(link: any) => link.dash ? [6, 4] : null}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={(node: any) => {
|
||||
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
|
||||
@@ -401,16 +464,29 @@ export function NoteGraphView() {
|
||||
|
||||
{/* Cluster legend (Interactive Notebook Filter) */}
|
||||
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[50vh] overflow-y-auto pr-1">
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[42vh] overflow-y-auto pr-1">
|
||||
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">{t('graphView.notebooks')}</span>
|
||||
{selectedNotebookId && (
|
||||
<button
|
||||
onClick={() => setSelectedNotebookId(null)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
|
||||
>
|
||||
<X size={10} />
|
||||
{t('graphView.resetFilter')}
|
||||
</button>
|
||||
{(selectedNotebookId || focusNodeId) && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{selectedNotebookId && (
|
||||
<button
|
||||
onClick={() => setSelectedNotebookId(null)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
|
||||
>
|
||||
<X size={10} />
|
||||
{t('graphView.resetFilter')}
|
||||
</button>
|
||||
)}
|
||||
{focusNodeId && (
|
||||
<button
|
||||
onClick={() => setFocusNodeId(null)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-indigo-200 text-indigo-600 rounded-full shadow-sm hover:bg-indigo-50 transition-all text-[9px] font-semibold w-fit"
|
||||
>
|
||||
<X size={10} />
|
||||
{t('graphView.resetFocus')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{rawData.clusters.map(c => {
|
||||
@@ -439,31 +515,69 @@ export function NoteGraphView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend of relationship types */}
|
||||
{!loading && !error && graphData.nodes.length > 0 && (
|
||||
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm max-w-xs select-none">
|
||||
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider mb-1">{t('graphView.relationshipTypes')}</h3>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">WikiLink (Manuel)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">Memory Echo (IA)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#f59e0b]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">Mention de titre</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#3b82f6]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">Tags partagés</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-[1px] rounded shrink-0 bg-[#e2e8f0]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">Similarité sémantique</span>
|
||||
{/* Filtres de liens + seuil sémantique */}
|
||||
{!loading && !error && rawData && (
|
||||
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setControlsOpen(v => !v)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-white/95 border border-border/40 rounded-lg shadow-sm text-[10px] font-semibold text-slate-700 w-fit"
|
||||
>
|
||||
<Filter size={12} />
|
||||
{t('graphView.linkFilters')}
|
||||
</button>
|
||||
{controlsOpen && (
|
||||
<div className="p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm space-y-2.5 select-none">
|
||||
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider">{t('graphView.relationshipTypes')}</h3>
|
||||
{([
|
||||
['explicit_link', t('graphView.edgeTypes.explicitLink')],
|
||||
['semantic_echo', t('graphView.edgeTypes.semanticEcho')],
|
||||
['title_mention', t('graphView.edgeTypes.titleMention')],
|
||||
['shared_label', t('graphView.edgeTypes.sharedLabel')],
|
||||
['jaccard', t('graphView.edgeTypes.jaccard')],
|
||||
] as [EdgeTypeKey, string][]).map(([key, label]) => (
|
||||
<label key={key} className="flex items-center gap-2.5 cursor-pointer text-[10px] text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={edgeFilters[key]}
|
||||
onChange={() => toggleEdgeFilter(key)}
|
||||
className="w-3.5 h-3.5 shrink-0 rounded border-2 border-slate-300 accent-indigo-600 cursor-pointer"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
{edgeFilters.semantic_echo && (
|
||||
<div className="pt-1 border-t border-border/30 space-y-1">
|
||||
<div className="flex items-center justify-between text-[9px] text-concrete/70">
|
||||
<span>{t('graphView.semanticThreshold')}</span>
|
||||
<span className="font-mono">{Math.round(semanticMinWeight * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={0.9}
|
||||
step={0.05}
|
||||
value={semanticMinWeight}
|
||||
onChange={e => setSemanticMinWeight(Number(e.target.value))}
|
||||
className="w-full h-1 accent-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend of relationship types (compact) */}
|
||||
{!loading && !error && graphData.nodes.length > 0 && controlsOpen && (
|
||||
<div className="absolute bottom-4 right-[21rem] z-10 hidden xl:flex flex-col gap-1.5 p-2.5 bg-white/90 border border-border/40 rounded-lg shadow-sm max-w-xs select-none pointer-events-none opacity-80">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
|
||||
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.explicitLink')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
|
||||
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.semanticEcho')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -605,9 +719,17 @@ export function NoteGraphView() {
|
||||
</div>
|
||||
|
||||
{/* Premium Action Footer */}
|
||||
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20">
|
||||
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20 space-y-2">
|
||||
<button
|
||||
onClick={() => router.push(`/notes/${selectedNode.id}`)}
|
||||
type="button"
|
||||
onClick={() => setFocusNodeId(prev => prev === selectedNode.id ? null : selectedNode.id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 bg-white dark:bg-stone-900 border border-border/50 hover:border-indigo-400 text-xs font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Sparkles size={12} className="text-indigo-500" />
|
||||
<span>{focusNodeId === selectedNode.id ? t('graphView.resetFocus') : t('graphView.exploreFromNode')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(openNotePath(selectedNode.id))}
|
||||
className="group w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-brand-accent hover:bg-brand-accent/90 text-white active:scale-[0.98] text-xs font-semibold rounded-lg shadow-sm transition-all duration-200"
|
||||
>
|
||||
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind } from 'lucide-react'
|
||||
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -179,6 +179,7 @@ export function NotificationPanel() {
|
||||
if (type === 'agent_failure') return { bg: 'rgba(239,68,68,0.12)', color: '#EF4444' }
|
||||
if (type === 'brainstorm_invite') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||
if (type === 'brainstorm_joined') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||
if (type === 'clip') return { bg: 'rgba(99,102,241,0.12)', color: '#6366F1' }
|
||||
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||
}
|
||||
|
||||
@@ -278,6 +279,7 @@ export function NotificationPanel() {
|
||||
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
|
||||
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
|
||||
: notif.type === 'brainstorm_joined' ? <Wind className="w-3.5 h-3.5" />
|
||||
: notif.type === 'clip' ? <Scissors className="w-3.5 h-3.5" />
|
||||
: notif.type.startsWith('agent') ? <Bot className="w-3.5 h-3.5" />
|
||||
: <AlertCircle className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
@@ -293,6 +295,7 @@ export function NotificationPanel() {
|
||||
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent échoué')}
|
||||
{notif.type === 'brainstorm_invite' && (t('notification.brainstormInvite') || 'Brainstorm')}
|
||||
{notif.type === 'brainstorm_joined' && (t('notification.brainstormJoined') || 'Brainstorm')}
|
||||
{notif.type === 'clip' && (t('notification.clipSaved') || 'Web clip')}
|
||||
{notif.type === 'system' && t('notification.systemNotification')}
|
||||
</span>
|
||||
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
|
||||
|
||||
@@ -25,9 +25,13 @@ 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 { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
@@ -60,6 +64,8 @@ interface RichTextEditorProps {
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
noteId?: string
|
||||
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
|
||||
sourceUrl?: string | null
|
||||
}
|
||||
|
||||
interface RichTextEditorRef {
|
||||
@@ -227,7 +233,7 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId }, ref) {
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
@@ -350,6 +356,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
ChartExtension,
|
||||
UniqueIdExtension,
|
||||
LiveBlockExtension,
|
||||
ClipArticleExtension,
|
||||
RtlPreserveExtension,
|
||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||
],
|
||||
content: content || '',
|
||||
@@ -428,6 +436,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
noteLinkRangeRef.current = null
|
||||
}
|
||||
},
|
||||
onCreate: ({ editor: e }) => {
|
||||
requestAnimationFrame(() => {
|
||||
applyClipRtlDirection(e, { sourceUrl })
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -442,12 +455,15 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
if (editor && content !== undefined && content !== lastEmittedContent.current) {
|
||||
editor.commands.setContent(content || '')
|
||||
lastEmittedContent.current = content || ''
|
||||
// TipTap #7338 : dir explicite rtl sur listes (pas auto) après chargement HTML
|
||||
requestAnimationFrame(() => {
|
||||
applyClipRtlDirection(editor, { sourceUrl })
|
||||
})
|
||||
}
|
||||
// Update current note content for chart suggestions
|
||||
if (content !== undefined) {
|
||||
setCurrentNoteContent(content || '')
|
||||
}
|
||||
}, [content, editor])
|
||||
}, [content, editor, sourceUrl])
|
||||
|
||||
// Chart suggestion handlers
|
||||
const handleOpenChartSuggestions = useCallback(async () => {
|
||||
@@ -463,8 +479,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
options?: { atEnd?: boolean }
|
||||
) => {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
const plainExcerpt = payload.excerpt.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const plainExcerpt = stripHtmlToPlainText(payload.excerpt)
|
||||
if (!plainExcerpt) return false
|
||||
const isRtl = detectTextDirection(`${payload.noteTitle}\n${plainExcerpt}`) === 'rtl'
|
||||
const rtlAttrs = isRtl ? { dir: 'rtl' as const, lang: 'fa' as const } : {}
|
||||
const chain = editor.chain()
|
||||
if (options?.atEnd !== false) {
|
||||
chain.focus('end')
|
||||
@@ -475,10 +493,16 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
{ type: 'paragraph', content: [] },
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: plainExcerpt }] }],
|
||||
attrs: rtlAttrs.dir ? { dir: rtlAttrs.dir } : {},
|
||||
content: [{
|
||||
type: 'paragraph',
|
||||
attrs: rtlAttrs,
|
||||
content: [{ type: 'text', text: plainExcerpt }],
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: rtlAttrs,
|
||||
content: [
|
||||
{ type: 'text', text: '— ' },
|
||||
{
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
Network,
|
||||
Search,
|
||||
GraduationCap,
|
||||
Scissors,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
@@ -958,8 +957,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
||||
{([
|
||||
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
|
||||
{ id: 'graph', icon: Network, label: 'Vue graphe', onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
|
||||
{ id: 'revision', icon: GraduationCap, label: 'Révisions', onClick: () => setActiveView('revision'), isActive: activeView === 'revision' },
|
||||
{ id: 'graph', icon: Network, label: t('nav.graphView'), onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
|
||||
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
|
||||
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => setActiveView('revision'), isActive: activeView === 'revision' },
|
||||
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
|
||||
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
|
||||
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
|
||||
|
||||
58
memento-note/components/tiptap-clip-article-extension.ts
Normal file
58
memento-note/components/tiptap-clip-article-extension.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
|
||||
/** Conteneur RTL pour articles clippés — préserve direction héritée (listes, paragraphes). */
|
||||
export const ClipArticleExtension = Node.create({
|
||||
name: 'clipArticle',
|
||||
group: 'block',
|
||||
content: '(block | bulletList | orderedList)+',
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
dir: {
|
||||
default: 'rtl',
|
||||
parseHTML: (element) => element.getAttribute('dir') || 'rtl',
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.dir) return { dir: 'rtl' }
|
||||
return { dir: attributes.dir }
|
||||
},
|
||||
},
|
||||
lang: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('lang'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.lang) return {}
|
||||
return { lang: attributes.lang }
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div.clip-article--rtl',
|
||||
priority: 60,
|
||||
},
|
||||
{
|
||||
tag: 'div.clip-article[dir="rtl"]',
|
||||
priority: 55,
|
||||
},
|
||||
{
|
||||
tag: 'div[dir="rtl"][class*="clip-article"]',
|
||||
priority: 50,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
class: 'clip-article clip-article--rtl',
|
||||
dir: 'rtl',
|
||||
}),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
46
memento-note/components/tiptap-rtl-preserve-extension.ts
Normal file
46
memento-note/components/tiptap-rtl-preserve-extension.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
/** Préserve dir/lang sur les blocs HTML (contenus clippés persan/arabe). */
|
||||
export const RtlPreserveExtension = Extension.create({
|
||||
name: 'rtlPreserve',
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: [
|
||||
'paragraph',
|
||||
'heading',
|
||||
'blockquote',
|
||||
'listItem',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
],
|
||||
attributes: {
|
||||
dir: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('dir'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.dir) return {}
|
||||
return { dir: attributes.dir }
|
||||
},
|
||||
},
|
||||
lang: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('lang'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.lang) return {}
|
||||
return { lang: attributes.lang }
|
||||
},
|
||||
},
|
||||
class: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('class'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.class) return {}
|
||||
return { class: attributes.class }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user