From 261eee2953e5bdb4822edf042ffc8a1b7c09eb63 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sat, 4 Jul 2026 23:37:37 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20factorisation=20peek=20panel=20?= =?UTF-8?q?=E2=80=94=20NotePeekPanel=20r=C3=A9utilisable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture: - components/note-peek/use-note-peek.ts: hook (fetch + state + events) - components/note-peek/note-peek-content.tsx: rendu markdown/richtext/KaTeX - components/note-peek/note-peek-panel.tsx: panel (overlay + inline modes) - components/note-peek/index.ts: exports - lib/use-scroll-to-block.ts: utilitaire scroll vers data-id insights/page.tsx: - ~95 lignes (state + fetch + KaTeX useEffect + AnimatePresence) → 10 lignes - peek.open(noteId) remplace handleNoteClick complexe - remplace tout le JSX du panel NotePeekPanel gère: - markdown (MarkdownContent) + richtext (DOMPurify + KaTeX lazy) - RTL (slide gauche pour fa/ar) - prefers-reduced-motion - role=dialog + aria-modal (overlay mode) - loading spinner - bouton Maximize2 + X - renderContent prop pour custom (éditeur read-only) --- .cursor/hooks/state/continual-learning.json | 4 +- memento-note/app/(main)/insights/page.tsx | 132 ++--------------- memento-note/components/note-peek/index.ts | 3 + .../note-peek/note-peek-content.tsx | 66 +++++++++ .../components/note-peek/note-peek-panel.tsx | 136 ++++++++++++++++++ .../components/note-peek/use-note-peek.ts | 60 ++++++++ memento-note/lib/use-scroll-to-block.ts | 19 +++ 7 files changed, 299 insertions(+), 121 deletions(-) create mode 100644 memento-note/components/note-peek/index.ts create mode 100644 memento-note/components/note-peek/note-peek-content.tsx create mode 100644 memento-note/components/note-peek/note-peek-panel.tsx create mode 100644 memento-note/components/note-peek/use-note-peek.ts create mode 100644 memento-note/lib/use-scroll-to-block.ts diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json index 7e83058..490f0f7 100644 --- a/.cursor/hooks/state/continual-learning.json +++ b/.cursor/hooks/state/continual-learning.json @@ -1,8 +1,8 @@ { "version": 1, "lastRunAtMs": 1783196565768, - "turnsSinceLastRun": 3, + "turnsSinceLastRun": 5, "lastTranscriptMtimeMs": 1783196565691.0437, - "lastProcessedGenerationId": "d7e4c90f-1f03-4437-a235-3553b9f9cb09", + "lastProcessedGenerationId": "7ac52bd5-5714-460a-9c3b-e4b21907d6d7", "trialStartedAtMs": null } diff --git a/memento-note/app/(main)/insights/page.tsx b/memento-note/app/(main)/insights/page.tsx index 7b0bd18..7c2227d 100644 --- a/memento-note/app/(main)/insights/page.tsx +++ b/memento-note/app/(main)/insights/page.tsx @@ -25,12 +25,7 @@ import { } from 'lucide-react' import { toast } from 'sonner' import Link from 'next/link' -import { getNoteById } from '@/app/actions/notes' -import type { Note as NoteFull } from '@/lib/types' -import { X, Maximize2 } from 'lucide-react' -import DOMPurify from 'dompurify' -import { MarkdownContent } from '@/components/markdown-content' -import 'katex/dist/katex.min.css' +import { useNotePeek, NotePeekPanel } from '@/components/note-peek' const NetworkGraph = dynamic( () => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })), @@ -105,8 +100,7 @@ export default function InsightsPage() { const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard') const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual') const [lastSyncTime, setLastSyncTime] = useState('') - const [peekNote, setPeekNote] = useState(null) - const [peekLoading, setPeekLoading] = useState(false) + const peek = useNotePeek() const prefersReducedMotion = useReducedMotion() useEffect(() => { @@ -265,40 +259,8 @@ export default function InsightsPage() { } } - const handleNoteClick = async (noteId: string) => { - setPeekLoading(true) - try { - const note = await getNoteById(noteId) - if (note) setPeekNote(note) - } catch { - toast.error(t('general.error')) - } finally { - setPeekLoading(false) - } - } - - const closePeek = () => setPeekNote(null) - - // Rendu KaTeX pour les notes richtext (data-latex) - useEffect(() => { - if (!peekNote || peekNote.isMarkdown) return - const timer = setTimeout(async () => { - const container = document.getElementById('insights-peek-content') - if (!container) return - const katex = (await import('katex')).default - container.querySelectorAll('.math-equation-block[data-latex], .inline-math[data-latex]').forEach(el => { - const latex = el.getAttribute('data-latex') || '' - const isDisplay = el.classList.contains('math-equation-block') - try { el.innerHTML = katex.renderToString(latex, { displayMode: isDisplay, throwOnError: false }) } catch {} - }) - }, 100) - return () => clearTimeout(timer) - }, [peekNote]) - - const openPeekFully = () => { - if (!peekNote) return - router.push(`/home?openNote=${peekNote.id}`) - setPeekNote(null) + const handleNoteClick = (noteId: string) => { + peek.open(noteId) } const isRtl = locale === 'fa' || locale === 'ar' @@ -910,83 +872,15 @@ export default function InsightsPage() { )} - {/* ── Peek panel (slide from right) ── */} - - {peekNote && ( - - {/* Peek header */} -
- - {peekLoading ? t('insightsView.loading') : (peekNote.title || t('insightsView.unknownNote'))} - -
- - -
-
- - {/* Peek content */} -
-
-

- {peekNote.title || t('insightsView.unknownNote')} -

- {peekNote.content ? ( - peekNote.isMarkdown ? ( - - ) : ( -
- ) - ) : ( -

- )} -
-
- - )} - + {/* ── Peek panel (factorisé) ── */} + { router.push(`/home?openNote=${n.id}`); peek.close() }} + />
) diff --git a/memento-note/components/note-peek/index.ts b/memento-note/components/note-peek/index.ts new file mode 100644 index 0000000..c489dc0 --- /dev/null +++ b/memento-note/components/note-peek/index.ts @@ -0,0 +1,3 @@ +export { useNotePeek } from './use-note-peek' +export { NotePeekPanel } from './note-peek-panel' +export { NotePeekContent } from './note-peek-content' diff --git a/memento-note/components/note-peek/note-peek-content.tsx b/memento-note/components/note-peek/note-peek-content.tsx new file mode 100644 index 0000000..e4f6e39 --- /dev/null +++ b/memento-note/components/note-peek/note-peek-content.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useEffect, useRef, type ReactNode } from 'react' +import DOMPurify from 'dompurify' +import { MarkdownContent } from '@/components/markdown-content' +import 'katex/dist/katex.min.css' +import type { Note } from '@/lib/types' + +interface NotePeekContentProps { + note: Note + className?: string + children?: ReactNode +} + +export function NotePeekContent({ note, className, children }: NotePeekContentProps) { + const htmlRef = useRef(null) + + useEffect(() => { + if (!htmlRef.current || note.isMarkdown) return + let cancelled = false + void (async () => { + const katex = (await import('katex')).default + if (cancelled || !htmlRef.current) return + htmlRef.current.querySelectorAll('.math-equation-block[data-latex], .inline-math[data-latex]').forEach(el => { + const latex = el.getAttribute('data-latex') || '' + const isDisplay = el.classList.contains('math-equation-block') + try { el.innerHTML = katex.renderToString(latex, { displayMode: isDisplay, throwOnError: false }) } catch {} + }) + })() + return () => { cancelled = true } + }, [note.id, note.content, note.isMarkdown]) + + if (children) return <>{children} + + if (note.isMarkdown) { + return + } + + return ( +
+ ) +} diff --git a/memento-note/components/note-peek/note-peek-panel.tsx b/memento-note/components/note-peek/note-peek-panel.tsx new file mode 100644 index 0000000..edcee0f --- /dev/null +++ b/memento-note/components/note-peek/note-peek-panel.tsx @@ -0,0 +1,136 @@ +'use client' + +import { type ReactNode } from 'react' +import { motion, AnimatePresence, useReducedMotion } from 'motion/react' +import { X, Maximize2, Loader2 } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' +import type { Note } from '@/lib/types' +import { NotePeekContent } from './note-peek-content' + +interface NotePeekPanelProps { + note: Note | null + blockId?: string + loading?: boolean + mode: 'overlay' | 'inline' + onClose: () => void + onOpenFully?: (note: Note) => void + labelKey?: string + renderContent?: (note: Note, blockId?: string) => ReactNode +} + +export function NotePeekPanel({ + note, + blockId, + loading = false, + mode, + onClose, + onOpenFully, + labelKey = 'notePeek.label', + renderContent, +}: NotePeekPanelProps) { + const { t, language } = useLanguage() + const isRtl = language === 'fa' || language === 'ar' + const prefersReducedMotion = useReducedMotion() + const isOpen = note !== null || loading + + const spring = prefersReducedMotion + ? { duration: 0 } + : { type: 'spring' as const, stiffness: 340, damping: 34 } + + const content = ( + <> +
+ + {loading + ? (t('notePeek.loading') || 'Loading…') + : (note?.title || t('notePeek.untitled') || t('insightsView.unknownNote'))} + +
+ {onOpenFully && note && ( + + )} + +
+
+ +
+ {loading ? ( +
+ +
+ ) : note ? ( +
+ {renderContent ? ( + renderContent(note, blockId) + ) : ( + <> +

+ {note.title || t('notePeek.untitled') || 'Untitled'} +

+ + + )} +
+ ) : null} +
+ + ) + + if (mode === 'overlay') { + return ( + + {isOpen && ( + + {content} + + )} + + ) + } + + // inline mode + return ( + + {isOpen && ( + + {content} + + )} + + ) +} diff --git a/memento-note/components/note-peek/use-note-peek.ts b/memento-note/components/note-peek/use-note-peek.ts new file mode 100644 index 0000000..32ee996 --- /dev/null +++ b/memento-note/components/note-peek/use-note-peek.ts @@ -0,0 +1,60 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { getNoteById } from '@/app/actions/notes' +import type { Note } from '@/lib/types' +import { + NOTE_PEEK_OPEN_EVENT, + NOTE_PEEK_CLOSE_EVENT, + type NotePeekOpenDetail, +} from '@/lib/note-peek-sync' + +interface UseNotePeekOptions { + selfNoteId?: string +} + +export function useNotePeek(opts: UseNotePeekOptions = {}) { + const { selfNoteId } = opts + const [note, setNote] = useState(null) + const [blockId, setBlockId] = useState(undefined) + const [loading, setLoading] = useState(false) + const [isOpen, setIsOpen] = useState(false) + + const open = useCallback(async (targetNoteId: string, targetBlockId?: string) => { + if (selfNoteId && targetNoteId === selfNoteId) return + setLoading(true) + setIsOpen(true) + setBlockId(targetBlockId) + try { + const fetched = await getNoteById(targetNoteId) + if (fetched) setNote(fetched) + } catch { + // silent + } finally { + setLoading(false) + } + }, [selfNoteId]) + + const close = useCallback(() => { + setIsOpen(false) + setNote(null) + setBlockId(undefined) + }, []) + + useEffect(() => { + const onOpen = (e: Event) => { + const detail = (e as CustomEvent).detail + if (!detail) return + void open(detail.noteId, detail.blockId) + } + const onClose = () => close() + window.addEventListener(NOTE_PEEK_OPEN_EVENT, onOpen) + window.addEventListener(NOTE_PEEK_CLOSE_EVENT, onClose) + return () => { + window.removeEventListener(NOTE_PEEK_OPEN_EVENT, onOpen) + window.removeEventListener(NOTE_PEEK_CLOSE_EVENT, onClose) + } + }, [open, close]) + + return { note, blockId, loading, isOpen, open, close } +} diff --git a/memento-note/lib/use-scroll-to-block.ts b/memento-note/lib/use-scroll-to-block.ts new file mode 100644 index 0000000..db48950 --- /dev/null +++ b/memento-note/lib/use-scroll-to-block.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from 'react' + +export function useScrollToBlock( + scrollRef: React.RefObject, + blockId: string | undefined, + deps: React.DependencyList, + delay = 450, +) { + useEffect(() => { + if (!blockId || !scrollRef.current) return + const timer = window.setTimeout(() => { + const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(blockId) : blockId + const el = scrollRef.current?.querySelector(`[data-id="${escaped}"]`) + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, delay) + return () => window.clearTimeout(timer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [blockId, delay, ...deps]) +}