import { Plugin, PluginKey, EditorState, Transaction } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; import { Node as PMNode } from 'prosemirror-model'; export const findReplaceKey = new PluginKey('nileFindReplace'); export interface FindMatch { from: number; to: number; } export interface FindState { query: string; caseSensitive: boolean; matches: FindMatch[]; activeIndex: number; } const EMPTY: FindState = { query: '', caseSensitive: false, matches: [], activeIndex: 0 }; type FindMeta = | { type: 'search'; query: string; caseSensitive?: boolean } | { type: 'next' } | { type: 'prev' } | { type: 'clear' }; function findMatches(doc: PMNode, query: string, caseSensitive: boolean): FindMatch[] { if (!query) return []; const matches: FindMatch[] = []; const needle = caseSensitive ? query : query.toLowerCase(); doc.descendants((node, pos) => { if (!node.isTextblock) return true; const text = node.textBetween(0, node.content.size, '\0', '\0'); const haystack = caseSensitive ? text : text.toLowerCase(); let index = 0; // eslint-disable-next-line no-cond-assign while ((index = haystack.indexOf(needle, index)) !== -1) { matches.push({ from: pos + 1 + index, to: pos + 1 + index + needle.length }); index += needle.length; } return false; }); return matches; } function applyMeta(state: FindState, meta: FindMeta, doc: PMNode): FindState { switch (meta.type) { case 'search': { const caseSensitive = meta.caseSensitive ?? state.caseSensitive; return { query: meta.query, caseSensitive, matches: findMatches(doc, meta.query, caseSensitive), activeIndex: 0, }; } case 'next': return state.matches.length ? { ...state, activeIndex: (state.activeIndex + 1) % state.matches.length } : state; case 'prev': return state.matches.length ? { ...state, activeIndex: (state.activeIndex - 1 + state.matches.length) % state.matches.length, } : state; case 'clear': default: return EMPTY; } } /** * Find & replace: match highlighting with an active match, controlled via * transaction meta. Replacement itself is a normal text transaction issued * by the host (see findReplaceCommands). */ export function findReplacePlugin(): Plugin { return new Plugin({ key: findReplaceKey, state: { init: () => EMPTY, apply(tr, prev) { const meta = tr.getMeta(findReplaceKey) as FindMeta | undefined; if (meta) return applyMeta(prev, meta, tr.doc); if (tr.docChanged && prev.query) { const matches = findMatches(tr.doc, prev.query, prev.caseSensitive); return { ...prev, matches, activeIndex: Math.min(prev.activeIndex, Math.max(0, matches.length - 1)), }; } return prev; }, }, props: { decorations(state) { const find = findReplaceKey.getState(state); if (!find || !find.matches.length) return null; return DecorationSet.create( state.doc, find.matches.map((match, index) => Decoration.inline(match.from, match.to, { class: index === find.activeIndex ? 'nile-wysiwyg__find-match nile-wysiwyg__find-active' : 'nile-wysiwyg__find-match', }) ) ); }, }, }); } export function getFindState(state: EditorState): FindState { return findReplaceKey.getState(state) ?? EMPTY; } export function searchTr(state: EditorState, query: string, caseSensitive?: boolean): Transaction { return state.tr.setMeta(findReplaceKey, { type: 'search', query, caseSensitive }); } export function findStepTr(state: EditorState, direction: 'next' | 'prev'): Transaction { return state.tr.setMeta(findReplaceKey, { type: direction }); } export function clearFindTr(state: EditorState): Transaction { return state.tr.setMeta(findReplaceKey, { type: 'clear' }); } /** Replaces the active match. Returns the transaction, or null when idle. */ export function replaceActiveTr(state: EditorState, replacement: string): Transaction | null { const find = getFindState(state); const match = find.matches[find.activeIndex]; if (!match) return null; return state.tr.insertText(replacement, match.from, match.to); } /** Replaces every match in one transaction. Returns null when idle. */ export function replaceAllTr(state: EditorState, replacement: string): Transaction | null { const find = getFindState(state); if (!find.matches.length) return null; const tr = state.tr; // Back to front so earlier positions stay valid. for (const match of [...find.matches].reverse()) { tr.insertText(replacement, match.from, match.to); } return tr; }