feat: Find & Replace dans l'éditeur (Ctrl+F) + corrections Toggle/Callout/Outline
- Find & Replace : barre flottante Ctrl+F, recherche instantanée synchrone - Highlights ProseMirror (jaune = match, orange = actif) - Scroll vers le match sans voler le focus de l'input - Options: sensible à la casse, regex - Remplacer / Tout remplacer - i18n FR/EN complet - Toggle/Callout/Outline: corrections bugs + design
This commit is contained in:
@@ -2985,4 +2985,18 @@ html.font-system * {
|
|||||||
|
|
||||||
.format-pill-btn:active {
|
.format-pill-btn:active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Find & Replace highlights */
|
||||||
|
.fr-match {
|
||||||
|
background-color: rgba(250, 204, 21, 0.35) !important;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fr-active {
|
||||||
|
background-color: rgba(249, 115, 22, 0.45) !important;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.3);
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
292
memento-note/components/editor-find-replace-bar.tsx
Normal file
292
memento-note/components/editor-find-replace-bar.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
'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
|
||||||
|
const sorted = [...ms].sort((a, b) => b.from - a.from)
|
||||||
|
editor.chain().focus()
|
||||||
|
sorted.forEach(m => editor.chain().insertContentAt({ from: m.from, to: m.to }, replaceText).run())
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } fr
|
|||||||
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
|
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
|
||||||
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
|
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
|
||||||
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
|
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
|
||||||
|
import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar'
|
||||||
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||||
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||||
@@ -306,6 +307,20 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [actionSheetOpen, setActionSheetOpen] = useState(false)
|
const [actionSheetOpen, setActionSheetOpen] = useState(false)
|
||||||
|
const [showFindReplace, setShowFindReplace] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFindShortcut = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'f' && !e.shiftKey) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return
|
||||||
|
e.preventDefault()
|
||||||
|
setShowFindReplace(v => !v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleFindShortcut)
|
||||||
|
return () => document.removeEventListener('keydown', handleFindShortcut)
|
||||||
|
}, [])
|
||||||
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
|
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
|
||||||
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
||||||
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
||||||
@@ -442,6 +457,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
ToggleExtension,
|
ToggleExtension,
|
||||||
CalloutExtension,
|
CalloutExtension,
|
||||||
OutlineExtension,
|
OutlineExtension,
|
||||||
|
FindReplaceExtension,
|
||||||
ClipArticleExtension,
|
ClipArticleExtension,
|
||||||
RtlPreserveExtension,
|
RtlPreserveExtension,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
@@ -1098,6 +1114,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
|
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
|
{editor && showFindReplace && (
|
||||||
|
<FindReplaceBar editor={editor} onClose={() => setShowFindReplace(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{editor && blockMenuState && (
|
{editor && blockMenuState && (
|
||||||
<BlockActionMenu
|
<BlockActionMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
|
|||||||
@@ -2416,6 +2416,17 @@
|
|||||||
"outlineTitle": "Outline",
|
"outlineTitle": "Outline",
|
||||||
"outlineEmpty": "Add headings (H1, H2, H3) to generate the outline",
|
"outlineEmpty": "Add headings (H1, H2, H3) to generate the outline",
|
||||||
"outlineDelete": "Delete outline",
|
"outlineDelete": "Delete outline",
|
||||||
|
"findReplaceTitle": "Find",
|
||||||
|
"findPlaceholder": "Find in note...",
|
||||||
|
"replacePlaceholder": "Replace with...",
|
||||||
|
"replace": "Replace",
|
||||||
|
"replaceAll": "Replace all",
|
||||||
|
"findNext": "Next match",
|
||||||
|
"findPrev": "Previous match",
|
||||||
|
"findCaseSensitive": "Case sensitive",
|
||||||
|
"findWholeWord": "Whole word",
|
||||||
|
"findRegex": "Regular expression",
|
||||||
|
"findReplaceToggle": "Show/Hide replace",
|
||||||
"calloutDelete": "Delete callout",
|
"calloutDelete": "Delete callout",
|
||||||
"calloutUnwrap": "Disable callout",
|
"calloutUnwrap": "Disable callout",
|
||||||
"calloutInfo": "Information",
|
"calloutInfo": "Information",
|
||||||
|
|||||||
@@ -2420,6 +2420,17 @@
|
|||||||
"outlineTitle": "Sommaire",
|
"outlineTitle": "Sommaire",
|
||||||
"outlineEmpty": "Ajoutez des titres (H1, H2, H3) pour générer le sommaire",
|
"outlineEmpty": "Ajoutez des titres (H1, H2, H3) pour générer le sommaire",
|
||||||
"outlineDelete": "Supprimer le sommaire",
|
"outlineDelete": "Supprimer le sommaire",
|
||||||
|
"findReplaceTitle": "Rechercher",
|
||||||
|
"findPlaceholder": "Rechercher dans la note...",
|
||||||
|
"replacePlaceholder": "Remplacer par...",
|
||||||
|
"replace": "Remplacer",
|
||||||
|
"replaceAll": "Tout remplacer",
|
||||||
|
"findNext": "Occurrence suivante",
|
||||||
|
"findPrev": "Occurrence précédente",
|
||||||
|
"findCaseSensitive": "Sensible à la casse",
|
||||||
|
"findWholeWord": "Mot entier",
|
||||||
|
"findRegex": "Expression régulière",
|
||||||
|
"findReplaceToggle": "Afficher/Masquer le remplacement",
|
||||||
"calloutDelete": "Supprimer l'encadré",
|
"calloutDelete": "Supprimer l'encadré",
|
||||||
"calloutUnwrap": "Désactiver l'encadré",
|
"calloutUnwrap": "Désactiver l'encadré",
|
||||||
"calloutInfo": "Information",
|
"calloutInfo": "Information",
|
||||||
|
|||||||
Reference in New Issue
Block a user