refactor: factorisation peek panel — NotePeekPanel réutilisable
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 - <NotePeekPanel mode=overlay /> 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)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string>('')
|
||||
const [peekNote, setPeekNote] = useState<NoteFull | null>(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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Peek panel (slide from right) ── */}
|
||||
<AnimatePresence>
|
||||
{peekNote && (
|
||||
<motion.aside
|
||||
key="insights-peek"
|
||||
initial={{ x: isRtl ? '-100%' : '100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: isRtl ? '-100%' : '100%', opacity: 0 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', stiffness: 340, damping: 34 }}
|
||||
className={`fixed top-0 ${isRtl ? 'left-0' : 'right-0'} z-[80] h-full w-full sm:w-[min(50vw,640px)] bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden shadow-2xl ${isRtl ? 'border-r' : 'border-l'} border-black/10 dark:border-white/10`}
|
||||
>
|
||||
{/* Peek header */}
|
||||
<div className="shrink-0 px-5 py-3 flex items-center justify-between gap-3 border-b border-black/[0.06] dark:border-white/[0.06] bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete truncate">
|
||||
{peekLoading ? t('insightsView.loading') : (peekNote.title || t('insightsView.unknownNote'))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={openPeekFully}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400 hover:bg-blue-500/10 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={closePeek}
|
||||
className="p-1.5 rounded-lg text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Peek content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
||||
<div className="max-w-2xl mx-auto w-full px-6 sm:px-8 py-8 pb-24">
|
||||
<h2 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-6">
|
||||
{peekNote.title || t('insightsView.unknownNote')}
|
||||
</h2>
|
||||
{peekNote.content ? (
|
||||
peekNote.isMarkdown ? (
|
||||
<MarkdownContent content={peekNote.content} className="prose-lg" />
|
||||
) : (
|
||||
<div
|
||||
id="insights-peek-content"
|
||||
dir="auto"
|
||||
className="editor-body prose prose-lg dark:prose-invert max-w-none leading-relaxed text-ink dark:text-dark-ink
|
||||
[&_h1]:text-2xl [&_h1]:font-serif [&_h1]:font-semibold [&_h1]:mt-8 [&_h1]:mb-4
|
||||
[&_h2]:text-xl [&_h2]:font-serif [&_h2]:font-semibold [&_h2]:mt-6 [&_h2]:mb-3
|
||||
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-5 [&_h3]:mb-2
|
||||
[&_p]:my-3 [&_p]:leading-relaxed
|
||||
[&_ul]:list-disc [&_ul]:ps-6 [&_ul]:my-3
|
||||
[&_ol]:list-decimal [&_ol]:ps-6 [&_ol]:my-3
|
||||
[&_li]:my-1
|
||||
[&_blockquote]:border-l-4 [&_blockquote]:border-brand-accent [&_blockquote]:ps-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:my-4
|
||||
[&_pre]:bg-zinc-100 [&_pre]:dark:bg-zinc-900 [&_pre]:p-4 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:my-4
|
||||
[&_code]:bg-zinc-100 [&_code]:dark:bg-zinc-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_code]:font-mono
|
||||
[&_a]:text-blue-600 [&_a]:underline
|
||||
[&_img]:rounded-lg [&_img]:max-w-full [&_img]:my-4
|
||||
[&_table]:w-full [&_table]:my-4 [&_table]:border-collapse
|
||||
[&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:bg-muted [&_th]:font-semibold [&_th]:text-left
|
||||
[&_td]:border [&_td]:border-border [&_td]:p-2
|
||||
[&_hr]:border-border [&_hr]:my-6
|
||||
[&_[data-callout-type]]:p-4 [&_[data-callout-type]]:rounded-lg [&_[data-callout-type]]:my-4
|
||||
[&_div[data-type='toggle-block']]:my-3
|
||||
[&_div[data-type='toggle-block']_details]:rounded-lg [&_div[data-type='toggle-block']_details]:border [&_div[data-type='toggle-block']_details]:border-border"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(peekNote.content) }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-concrete italic">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* ── Peek panel (factorisé) ── */}
|
||||
<NotePeekPanel
|
||||
note={peek.note}
|
||||
blockId={peek.blockId}
|
||||
loading={peek.loading}
|
||||
mode="overlay"
|
||||
onClose={peek.close}
|
||||
onOpenFully={(n) => { router.push(`/home?openNote=${n.id}`); peek.close() }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
3
memento-note/components/note-peek/index.ts
Normal file
3
memento-note/components/note-peek/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useNotePeek } from './use-note-peek'
|
||||
export { NotePeekPanel } from './note-peek-panel'
|
||||
export { NotePeekContent } from './note-peek-content'
|
||||
66
memento-note/components/note-peek/note-peek-content.tsx
Normal file
66
memento-note/components/note-peek/note-peek-content.tsx
Normal file
@@ -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<HTMLDivElement>(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 <MarkdownContent content={note.content} className={className} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={htmlRef}
|
||||
dir="auto"
|
||||
className={`editor-body prose prose-lg dark:prose-invert max-w-none leading-relaxed text-ink dark:text-dark-ink
|
||||
[&_h1]:text-2xl [&_h1]:font-serif [&_h1]:font-semibold [&_h1]:mt-8 [&_h1]:mb-4
|
||||
[&_h2]:text-xl [&_h2]:font-serif [&_h2]:font-semibold [&_h2]:mt-6 [&_h2]:mb-3
|
||||
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-5 [&_h3]:mb-2
|
||||
[&_p]:my-3 [&_p]:leading-relaxed
|
||||
[&_ul]:list-disc [&_ul]:ps-6 [&_ul]:my-3
|
||||
[&_ol]:list-decimal [&_ol]:ps-6 [&_ol]:my-3
|
||||
[&_li]:my-1
|
||||
[&_blockquote]:border-l-4 [&_blockquote]:border-brand-accent [&_blockquote]:ps-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:my-4
|
||||
[&_pre]:bg-zinc-100 [&_pre]:dark:bg-zinc-900 [&_pre]:p-4 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:my-4
|
||||
[&_code]:bg-zinc-100 [&_code]:dark:bg-zinc-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_code]:font-mono
|
||||
[&_a]:text-blue-600 [&_a]:underline
|
||||
[&_img]:rounded-lg [&_img]:max-w-full [&_img]:my-4
|
||||
[&_table]:w-full [&_table]:my-4 [&_table]:border-collapse
|
||||
[&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:bg-muted [&_th]:font-semibold [&_th]:text-left
|
||||
[&_td]:border [&_td]:border-border [&_td]:p-2
|
||||
[&_hr]:border-border [&_hr]:my-6
|
||||
[&_[data-callout-type]]:p-4 [&_[data-callout-type]]:rounded-lg [&_[data-callout-type]]:my-4
|
||||
[&_div[data-type='toggle-block']]:my-3
|
||||
[&_div[data-type='toggle-block']_details]:rounded-lg [&_div[data-type='toggle-block']_details]:border [&_div[data-type='toggle-block']_details]:border-border ${className || ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(note.content) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
136
memento-note/components/note-peek/note-peek-panel.tsx
Normal file
136
memento-note/components/note-peek/note-peek-panel.tsx
Normal file
@@ -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 = (
|
||||
<>
|
||||
<div className="shrink-0 px-5 py-3 flex items-center justify-between gap-3 border-b border-black/[0.06] dark:border-white/[0.06] bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete truncate">
|
||||
{loading
|
||||
? (t('notePeek.loading') || 'Loading…')
|
||||
: (note?.title || t('notePeek.untitled') || t('insightsView.unknownNote'))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onOpenFully && note && (
|
||||
<button
|
||||
onClick={() => onOpenFully(note)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400 hover:bg-blue-500/10 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-brand-accent/50 focus-visible:outline-none"
|
||||
title={t('notePeek.openFullyHelp') || 'Open full screen'}
|
||||
aria-label={t('notePeek.openFully') || 'Open fully'}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-brand-accent/50 focus-visible:outline-none"
|
||||
aria-label={t('notePeek.close') || 'Close'}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="animate-spin text-brand-accent/40" size={28} />
|
||||
</div>
|
||||
) : note ? (
|
||||
<div className="max-w-2xl mx-auto w-full px-6 sm:px-8 py-8 pb-24">
|
||||
{renderContent ? (
|
||||
renderContent(note, blockId)
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-6">
|
||||
{note.title || t('notePeek.untitled') || 'Untitled'}
|
||||
</h2>
|
||||
<NotePeekContent note={note} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (mode === 'overlay') {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.aside
|
||||
key="note-peek-overlay"
|
||||
initial={{ x: isRtl ? '-100%' : '100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: isRtl ? '-100%' : '100%', opacity: 0 }}
|
||||
transition={spring}
|
||||
className={`fixed top-0 ${isRtl ? 'left-0' : 'right-0'} z-[80] h-full w-full sm:w-[min(50vw,640px)] bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden shadow-2xl ${isRtl ? 'border-r' : 'border-l'} border-black/10 dark:border-white/10`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t(labelKey) || 'Note preview'}
|
||||
>
|
||||
{content}
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// inline mode
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.aside
|
||||
key="note-peek-inline"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'min(50vw, 720px)', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={spring}
|
||||
className={`shrink-0 h-full min-h-0 bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden z-40 ${
|
||||
isRtl
|
||||
? 'border-r border-black/10 dark:border-white/10 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.12)]'
|
||||
: 'border-l border-black/10 dark:border-white/10 shadow-[-4px_0_24px_-12px_rgba(0,0,0,0.12)]'
|
||||
}`}
|
||||
aria-label={t(labelKey) || 'Note preview'}
|
||||
>
|
||||
{content}
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
60
memento-note/components/note-peek/use-note-peek.ts
Normal file
60
memento-note/components/note-peek/use-note-peek.ts
Normal file
@@ -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<Note | null>(null)
|
||||
const [blockId, setBlockId] = useState<string | undefined>(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<NotePeekOpenDetail>).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 }
|
||||
}
|
||||
19
memento-note/lib/use-scroll-to-block.ts
Normal file
19
memento-note/lib/use-scroll-to-block.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export function useScrollToBlock(
|
||||
scrollRef: React.RefObject<HTMLElement | null>,
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user