import { Extension } from '@tiptap/core' import { Plugin, PluginKey } from '@tiptap/pm/state' import type { EditorState, Transaction } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' import type { Node as PMNode } from '@tiptap/pm/model' import { mapOffset } from './SuggestionHighlight' // SearchHighlight powers the in-document Find & Replace bar. Like the suggestion // layer it uses ProseMirror *decorations* (not stored marks), so matches are // recomputed from the live document on every edit and never leave a stale // highlight behind. Matches are found per-textblock (a query never spans a // paragraph break), mirroring how the rest of the editor anchors text. export interface Match { from: number to: number } interface PluginState { query: string caseSensitive: boolean matches: Match[] active: number // index into matches, or -1 when there are none decorations: DecorationSet } export const searchPluginKey = new PluginKey('petalSearch') // findMatches collects every occurrence of `query` across the document's // textblocks and maps each to an absolute ProseMirror range. function findMatches(doc: PMNode, query: string, caseSensitive: boolean): Match[] { if (!query) return [] const needle = caseSensitive ? query : query.toLowerCase() const matches: Match[] = [] doc.descendants((node, pos) => { if (!node.isTextblock) return true const haystackRaw = node.textContent const haystack = caseSensitive ? haystackRaw : haystackRaw.toLowerCase() let idx = haystack.indexOf(needle) while (idx >= 0) { matches.push({ from: mapOffset(node, pos, idx), to: mapOffset(node, pos, idx + query.length), }) idx = haystack.indexOf(needle, idx + query.length) } return false // don't descend into inline children }) return matches } function build(doc: PMNode, query: string, caseSensitive: boolean, preferred: number): PluginState { const matches = findMatches(doc, query, caseSensitive) const active = matches.length === 0 ? -1 : Math.max(0, Math.min(preferred, matches.length - 1)) const decos = matches.map((m, i) => Decoration.inline(m.from, m.to, { class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match', }), ) return { query, caseSensitive, matches, active, decorations: DecorationSet.create(doc, decos) } } const EMPTY: PluginState = { query: '', caseSensitive: false, matches: [], active: -1, decorations: DecorationSet.empty, } // setSearch updates the query / case-sensitivity and recomputes matches. Passing // an empty query clears the layer. export function setSearch( state: EditorState, dispatch: (tr: Transaction) => void, query: string, caseSensitive: boolean, ) { dispatch(state.tr.setMeta(searchPluginKey, { kind: 'search', query, caseSensitive })) } // setActive moves the highlighted (current) match by index. export function setActive(state: EditorState, dispatch: (tr: Transaction) => void, index: number) { dispatch(state.tr.setMeta(searchPluginKey, { kind: 'active', index })) } // clearSearch tears the layer down (on close). export function clearSearch(state: EditorState, dispatch: (tr: Transaction) => void) { dispatch(state.tr.setMeta(searchPluginKey, { kind: 'clear' })) } // getSearchState reads the live plugin state (match list + active index) so the // Find bar can render "n / m" and drive navigation/replacement. export function getSearchState(state: EditorState): PluginState { return searchPluginKey.getState(state) ?? EMPTY } type Meta = | { kind: 'search'; query: string; caseSensitive: boolean } | { kind: 'active'; index: number } | { kind: 'clear' } export const SearchHighlight = Extension.create({ name: 'searchHighlight', addProseMirrorPlugins() { return [ new Plugin({ key: searchPluginKey, state: { init: () => EMPTY, apply(tr, value, _oldState, newState): PluginState { const meta = tr.getMeta(searchPluginKey) as Meta | undefined if (meta?.kind === 'search') { return build(newState.doc, meta.query, meta.caseSensitive, value.active < 0 ? 0 : value.active) } if (meta?.kind === 'active') { if (value.matches.length === 0) return value const active = ((meta.index % value.matches.length) + value.matches.length) % value.matches.length const decos = value.matches.map((m, i) => Decoration.inline(m.from, m.to, { class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match', }), ) return { ...value, active, decorations: DecorationSet.create(newState.doc, decos) } } if (meta?.kind === 'clear') return EMPTY // Re-anchor on any document change so highlights track edits/replaces. if (tr.docChanged && value.query) { return build(newState.doc, value.query, value.caseSensitive, value.active) } return value }, }, props: { decorations(state) { return searchPluginKey.getState(state)?.decorations }, }, }), ] }, })