diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index cbd30f6..0ea8005 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -2985,4 +2985,18 @@ html.font-system * { .format-pill-btn:active { 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; } \ No newline at end of file diff --git a/memento-note/components/editor-find-replace-bar.tsx b/memento-note/components/editor-find-replace-bar.tsx new file mode 100644 index 0000000..6eed44b --- /dev/null +++ b/memento-note/components/editor-find-replace-bar.tsx @@ -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('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(null) + + const matchesRef = useRef([]) + 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( +
e.stopPropagation()} + > +
+ {t('richTextEditor.findReplaceTitle')} + +
+ +
+
+ { + 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" + /> + + {count > 0 ? `${currentIndex + 1}/${count}` : `0/0`} + +
+ +
+ + + +
+ + +
+ + {showReplace && ( +
+ 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" + /> + + +
+ )} +
+
, + document.body + ) +} diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index fc81383..15c146f 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -29,6 +29,7 @@ import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } fr import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension' import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension' import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension' +import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar' import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension' import { ClipArticleExtension } from './tiptap-clip-article-extension' import { BlockPicker, type BlockSuggestion } from './block-picker' @@ -306,6 +307,20 @@ export const RichTextEditor = forwardRef(null) const [isMobile, setIsMobile] = 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 [noteLinkQuery, setNoteLinkQuery] = useState('') const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null) @@ -442,6 +457,7 @@ export const RichTextEditor = forwardRef + {editor && showFindReplace && ( + setShowFindReplace(false)} /> + )} + {editor && blockMenuState && (