Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./packages/react/src/components/DocxEditor/hooks/useFindReplaceBridge.ts
import { useCallback, useRef } from 'react';
import { TextSelection } from 'prosemirror-state';
import type { EditorView } from 'prosemirror-view';
import {
findAllMatches,
type FindMatch,
type FindOptions,
type FindResult,
} from '../../dialogs/findReplaceUtils';
import type { useFindReplace } from '../../../hooks/useFindReplace';
import type { PagedEditorRef } from '../PagedEditor';
type PmFindMatch = FindMatch & {
pmFrom: number;
pmTo: number;
};
type PmFindResult = FindResult & {
matches: PmFindMatch[];
};
type TextSegment = {
text: string;
pmStart: number;
};
function resolveTextOffset(segments: TextSegment[], offset: number, bias: 'start' | 'end') {
let textOffset = 0;
for (const segment of segments) {
const nextOffset = textOffset + segment.text.length;
if (
(bias === 'start' && offset >= textOffset && offset < nextOffset) ||
(bias === 'end' && offset > textOffset && offset <= nextOffset)
) {
return segment.pmStart + offset - textOffset;
}
textOffset = nextOffset;
}
return null;
}
function findMatchesInView(view: EditorView, searchText: string, options: FindOptions) {
const matches: PmFindMatch[] = [];
let paragraphIndex = 0;
view.state.doc.descendants((node, pos) => {
if (!node.isTextblock) return true;
const segments: TextSegment[] = [];
let paragraphText = '';
node.descendants((child, childOffset) => {
if (!child.isText || !child.text) return true;
segments.push({
text: child.text,
pmStart: pos + 1 + childOffset,
});
paragraphText += child.text;
return true;
});
if (!paragraphText) {
paragraphIndex += 1;
return false;
}
const textMatches = findAllMatches(paragraphText, searchText, options);
for (const match of textMatches) {
const pmFrom = resolveTextOffset(segments, match.start, 'start');
const pmTo = resolveTextOffset(segments, match.end, 'end');
if (pmFrom == null || pmTo == null || pmFrom >= pmTo) continue;
matches.push({
paragraphIndex,
contentIndex: 0,
startOffset: match.start,
endOffset: match.end,
text: paragraphText.slice(match.start, match.end),
pmFrom,
pmTo,
});
}
paragraphIndex += 1;
return false;
});
return matches;
}
/**
* Bridges the find/replace dialog hook to the paged editor: searches the
* live ProseMirror document, selects the active PM range, and scrolls the
* visible pages through PagedEditor's scroll API. The dialog UI state itself
* (open/closed, current term) lives in `findReplace`.
*/
export function useFindReplaceBridge({
pagedEditorRef,
findReplace,
}: {
pagedEditorRef: React.RefObject<PagedEditorRef | null>;
findReplace: ReturnType<typeof useFindReplace>;
}) {
const findResultRef = useRef<FindResult | null>(null);
const getView = useCallback(() => pagedEditorRef.current?.getView() ?? null, [pagedEditorRef]);
const goToMatch = useCallback(
(match: PmFindMatch | undefined, index: number): FindMatch | null => {
const view = getView();
if (!view || !match) return null;
try {
const selection = TextSelection.create(view.state.doc, match.pmFrom, match.pmTo);
view.dispatch(view.state.tr.setSelection(selection));
pagedEditorRef.current?.scrollToPosition(match.pmFrom);
} catch (error) {
console.error('Find navigation failed:', error);
return null;
}
const currentResult = findResultRef.current as PmFindResult | null;
if (currentResult) {
currentResult.currentIndex = index;
}
findReplace.goToMatch(index);
return match;
},
[findReplace, getView, pagedEditorRef]
);
const handleFind = useCallback(
(searchText: string, options: FindOptions): FindResult | null => {
const view = getView();
if (!view || !searchText.trim()) {
findResultRef.current = null;
findReplace.setMatches([], 0);
return null;
}
const matches = findMatchesInView(view, searchText, options);
const result: PmFindResult = {
matches,
totalCount: matches.length,
currentIndex: 0,
};
findResultRef.current = result;
findReplace.setMatches(matches, 0);
if (matches.length > 0) {
goToMatch(matches[0], 0);
}
return result;
},
[findReplace, getView, goToMatch]
);
const handleFindNext = useCallback((): FindMatch | null => {
const result = findResultRef.current as PmFindResult | null;
if (!result || result.matches.length === 0) {
return null;
}
const newIndex = (result.currentIndex + 1) % result.matches.length;
return goToMatch(result.matches[newIndex], newIndex);
}, [goToMatch]);
const handleFindPrevious = useCallback((): FindMatch | null => {
const result = findResultRef.current as PmFindResult | null;
if (!result || result.matches.length === 0) {
return null;
}
const newIndex =
result.currentIndex === 0 ? result.matches.length - 1 : result.currentIndex - 1;
return goToMatch(result.matches[newIndex], newIndex);
}, [goToMatch]);
const handleReplace = useCallback(
(replaceText: string): boolean => {
const view = getView();
const result = findResultRef.current as PmFindResult | null;
if (!view || !result || result.matches.length === 0) {
return false;
}
const currentMatch = result.matches[result.currentIndex];
if (!currentMatch) return false;
try {
const tr = replaceText
? view.state.tr.replaceWith(
currentMatch.pmFrom,
currentMatch.pmTo,
view.state.schema.text(replaceText)
)
: view.state.tr.delete(currentMatch.pmFrom, currentMatch.pmTo);
view.dispatch(tr);
return true;
} catch (error) {
console.error('Replace failed:', error);
return false;
}
},
[getView]
);
const handleReplaceAll = useCallback(
(searchText: string, replaceText: string, options: FindOptions): number => {
const view = getView();
if (!view || !searchText.trim()) {
return 0;
}
const matches = findMatchesInView(view, searchText, options);
if (matches.length === 0) return 0;
// Apply from end to start so earlier match indices stay valid.
const sortedMatches = [...matches].sort((a, b) => b.pmFrom - a.pmFrom);
let tr = view.state.tr;
for (const match of sortedMatches) {
try {
tr = replaceText
? tr.replaceWith(match.pmFrom, match.pmTo, view.state.schema.text(replaceText))
: tr.delete(match.pmFrom, match.pmTo);
} catch (error) {
console.error('Replace failed for match:', match, error);
}
}
view.dispatch(tr);
findResultRef.current = null;
findReplace.setMatches([], 0);
return matches.length;
},
[findReplace, getView]
);
return {
findResultRef,
handleFind,
handleFindNext,
handleFindPrevious,
handleReplace,
handleReplaceAll,
};
}