Files
Momento/memento-note/components/editor-find-replace-bar.tsx
Antigravity ee70e74bf5
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Successful in 22s
fix: 5 bugs critiques de l'éditeur (Phase 1 audit)
1. replaceAll (Find & Replace) — une seule transaction ProseMirror
   au lieu d'un forEach cassé. Tous les matchs sont maintenant remplacés.

2. Link Preview unwrap — deleteNode() au lieu de clearer les attrs
   qui laissaient un nœud fantôme invisible dans le document.

3. Conversion Markdown → richtext — breaks: true dans marked.parse()
   Les simple newlines sont maintenant convertis en <br>.
   + préserve les blocs custom (toggle, callout, math, columns,
   outline, link-preview) en commentaires HTML lors de l'export MD.

4. emitNoteChange exercices — shape corrigée (type:'created' attend
   un objet Note, pas noteId/notebookId séparés).

5. Raccourcis clavier sans conflit :
   Cmd+Shift+C → Cmd+Alt+C (callout, avant: copier)
   Cmd+Shift+O → Cmd+Alt+O (outline, avant: historique/signets)
   Cmd+Shift+L → Cmd+Alt+L (colonnes, avant: lock screen macOS)
2026-06-20 15:48:18 +00:00

298 lines
11 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { X, ChevronDown, ChevronUp, Replace, CaseSensitive, Regex } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { Extension } from '@tiptap/core'
import { Editor } from '@tiptap/core'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
interface Match {
from: number
to: number
}
const pluginKey = new PluginKey<DecorationState>('findReplaceDecorations')
interface DecorationState {
decorations: DecorationSet
currentIndex: number
}
function findMatches(doc: any, query: string, caseSensitive: boolean, useRegex: boolean): Match[] {
if (!query) return []
let pattern: RegExp
try {
if (useRegex) {
pattern = new RegExp(query, caseSensitive ? 'g' : 'gi')
} else {
pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), caseSensitive ? 'g' : 'gi')
}
} catch { return [] }
const results: Match[] = []
doc.descendants((node: any, pos: number) => {
if (!node.isText || !node.text) return
pattern.lastIndex = 0
let m: RegExpExecArray | null
while ((m = pattern.exec(node.text)) !== null) {
if (m[0].length === 0) { pattern.lastIndex++; continue }
results.push({ from: pos + m.index, to: pos + m.index + m[0].length })
}
})
return results
}
export const FindReplaceExtension = Extension.create({
name: 'findReplace',
addProseMirrorPlugins() {
return [
new Plugin({
key: pluginKey,
state: {
init(): DecorationState {
return { decorations: DecorationSet.empty, currentIndex: -1 }
},
apply(tr, old: DecorationState): DecorationState {
const meta = tr.getMeta(pluginKey)
if (meta) return meta
if (tr.docChanged) {
return { decorations: DecorationSet.empty, currentIndex: -1 }
}
return old || { decorations: DecorationSet.empty, currentIndex: -1 }
},
},
props: {
decorations(state) {
return pluginKey.getState(state)?.decorations || null
},
},
}),
]
},
})
export function FindReplaceBar({ editor, onClose }: { editor: Editor; onClose: () => void }) {
const { t } = useLanguage()
const [searchText, setSearchText] = useState('')
const [replaceText, setReplaceText] = useState('')
const [caseSensitive, setCaseSensitive] = useState(false)
const [useRegex, setUseRegex] = useState(false)
const [count, setCount] = useState(0)
const [currentIndex, setCurrentIndex] = useState(-1)
const [showReplace, setShowReplace] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const matchesRef = useRef<Match[]>([])
const indexRef = useRef(-1)
const applyDecorations = useCallback((matches: Match[], activeIdx: number) => {
const decos = matches.map((m, i) =>
Decoration.inline(m.from, m.to, {
class: i === activeIdx ? 'fr-active' : 'fr-match',
})
)
const set = DecorationSet.create(editor.state.doc, decos)
const tr = editor.state.tr.setMeta(pluginKey, { decorations: set, currentIndex: activeIdx })
tr.setMeta('addToHistory', false)
editor.view.dispatch(tr)
}, [editor])
const scrollToMatch = useCallback((m: Match) => {
try {
const dom = editor.view.domAtPos(m.from)
const el = dom.node instanceof HTMLElement ? dom.node : (dom.node as any).parentElement
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
} catch {}
}, [editor])
const runSearch = useCallback((text: string) => {
if (!text.trim()) {
matchesRef.current = []
setCount(0)
setCurrentIndex(-1)
indexRef.current = -1
applyDecorations([], -1)
return
}
const found = findMatches(editor.state.doc, text, caseSensitive, useRegex)
matchesRef.current = found
setCount(found.length)
const idx = found.length > 0 ? 0 : -1
setCurrentIndex(idx)
indexRef.current = idx
applyDecorations(found, idx)
if (idx >= 0) scrollToMatch(found[idx])
}, [caseSensitive, useRegex, editor, applyDecorations, scrollToMatch])
useEffect(() => { inputRef.current?.focus() }, [])
const next = useCallback(() => {
const ms = matchesRef.current
if (ms.length === 0) return
const ni = (indexRef.current + 1) % ms.length
indexRef.current = ni
setCurrentIndex(ni)
applyDecorations(ms, ni)
scrollToMatch(ms[ni])
}, [applyDecorations, scrollToMatch])
const prev = useCallback(() => {
const ms = matchesRef.current
if (ms.length === 0) return
const ni = (indexRef.current - 1 + ms.length) % ms.length
indexRef.current = ni
setCurrentIndex(ni)
applyDecorations(ms, ni)
scrollToMatch(ms[ni])
}, [applyDecorations, scrollToMatch])
const replaceOne = useCallback(() => {
const ms = matchesRef.current
const idx = indexRef.current
if (idx < 0 || ms.length === 0) return
const m = ms[idx]
editor.chain().focus().insertContentAt({ from: m.from, to: m.to }, replaceText).run()
setTimeout(() => {
const found = findMatches(editor.state.doc, searchText, caseSensitive, useRegex)
matchesRef.current = found
setCount(found.length)
const ni = found.length > 0 ? Math.min(idx, found.length - 1) : -1
indexRef.current = ni
setCurrentIndex(ni)
applyDecorations(found, ni)
if (ni >= 0) scrollToMatch(found[ni])
}, 50)
}, [editor, replaceText, searchText, caseSensitive, useRegex, applyDecorations, scrollToMatch])
const replaceAll = useCallback(() => {
const ms = matchesRef.current
if (ms.length === 0) return
// Sort descending by position so replacements don't shift earlier positions
const sorted = [...ms].sort((a, b) => b.from - a.from)
// Use a single ProseMirror transaction for all replacements
const tr = editor.state.tr
for (const m of sorted) {
tr.insertText(replaceText, m.from, m.to)
}
editor.view.dispatch(tr)
matchesRef.current = []
setCount(0)
setCurrentIndex(-1)
indexRef.current = -1
applyDecorations([], -1)
}, [editor, replaceText, applyDecorations])
return createPortal(
<div
className="fixed top-4 right-4 z-[9999] w-80 rounded-xl border border-border bg-popover shadow-xl"
dir="auto"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<span className="text-sm font-medium">{t('richTextEditor.findReplaceTitle')}</span>
<button onClick={onClose} className="p-0.5 rounded hover:bg-muted text-muted-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="p-3 space-y-2">
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="text"
value={searchText}
onChange={(e) => {
setSearchText(e.target.value)
runSearch(e.target.value)
}}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? prev() : next() }
if (e.key === 'Escape') onClose()
}}
placeholder={t('richTextEditor.findPlaceholder')}
className="flex-1 min-w-0 rounded-md border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap px-1">
{count > 0 ? `${currentIndex + 1}/${count}` : `0/0`}
</span>
</div>
<div className="flex items-center gap-0.5">
<button
onClick={() => setCaseSensitive(v => !v)}
className={cn('p-1.5 rounded hover:bg-muted text-muted-foreground transition-colors', caseSensitive && 'bg-primary/10 text-primary')}
title={t('richTextEditor.findCaseSensitive')}
>
<CaseSensitive className="h-4 w-4" />
</button>
<button
onClick={() => setUseRegex(v => !v)}
className={cn('p-1.5 rounded hover:bg-muted text-muted-foreground transition-colors', useRegex && 'bg-primary/10 text-primary')}
title={t('richTextEditor.findRegex')}
>
<Regex className="h-4 w-4" />
</button>
<button
onClick={() => setShowReplace(v => !v)}
className={cn('p-1.5 rounded hover:bg-muted text-muted-foreground transition-colors', showReplace && 'bg-muted text-foreground')}
title={t('richTextEditor.findReplaceToggle')}
>
<Replace className="h-4 w-4" />
</button>
<div className="flex-1" />
<button
onClick={prev}
disabled={count === 0}
className="p-1.5 rounded hover:bg-muted text-muted-foreground disabled:opacity-40 disabled:cursor-not-allowed"
title={t('richTextEditor.findPrev')}
>
<ChevronUp className="h-4 w-4" />
</button>
<button
onClick={next}
disabled={count === 0}
className="p-1.5 rounded hover:bg-muted text-muted-foreground disabled:opacity-40 disabled:cursor-not-allowed"
title={t('richTextEditor.findNext')}
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
{showReplace && (
<div className="flex items-center gap-1 pt-1 border-t border-border/30">
<input
type="text"
value={replaceText}
onChange={(e) => setReplaceText(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder={t('richTextEditor.replacePlaceholder')}
className="flex-1 min-w-0 rounded-md border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
onClick={replaceOne}
disabled={currentIndex < 0}
className="px-2 py-1 text-xs rounded-md hover:bg-muted text-muted-foreground disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
{t('richTextEditor.replace')}
</button>
<button
onClick={replaceAll}
disabled={count === 0}
className="px-2 py-1 text-xs rounded-md bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap font-medium"
>
{t('richTextEditor.replaceAll')}
</button>
</div>
)}
</div>
</div>,
document.body
)
}