一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
225 行
6.5 KiB
TypeScript
225 行
6.5 KiB
TypeScript
import { HighlightedWord } from '@joplin/lib/reducer';
|
|
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
|
import { themeStyle } from '@joplin/lib/theme';
|
|
import { Theme } from '@joplin/lib/themes/type';
|
|
import { useEffect, useMemo } from 'react';
|
|
import { Editor, EditorEvent } from 'tinymce';
|
|
|
|
declare global {
|
|
interface Window {
|
|
Highlight: typeof Highlight;
|
|
Range: typeof Range;
|
|
CSS: typeof CSS;
|
|
}
|
|
}
|
|
|
|
const useSearchRegexes = (searchTerms: HighlightedWord[]) => {
|
|
return useMemo(() => {
|
|
return searchTerms.map((term: HighlightedWord) => {
|
|
let text;
|
|
if (typeof term === 'string') {
|
|
text = term;
|
|
} else {
|
|
text = term.value;
|
|
}
|
|
|
|
// Only highlight short matches at word boundaries (roughly matching the note viewer
|
|
// behavior)
|
|
const regexText = SearchEngine.instance().queryTermToRegex(text, { anchorToWordBoundary: text.length <= 2 });
|
|
return regexText ? new RegExp(regexText, 'ig') : null;
|
|
}).filter(regex => !!regex);
|
|
}, [searchTerms]);
|
|
};
|
|
|
|
const searchHighlightKey = 'jop-search-highlight';
|
|
|
|
const useHighlightStyleSheet = (editor: Editor, themeId: number) => {
|
|
useEffect(() => {
|
|
if (!editor) {
|
|
return () => {};
|
|
}
|
|
|
|
const theme: Theme = themeStyle(themeId);
|
|
const style = editor.dom.create('style', {}, `
|
|
/* Avoid showing highlights when the user has the find dialog open and
|
|
there are TinyMCE-provided search results */
|
|
body:not(:has(span.mce-match-marker)) {
|
|
::highlight(${searchHighlightKey}) {
|
|
background-color: ${theme.searchMarkerBackgroundColor};
|
|
color: ${theme.searchMarkerColor};
|
|
}
|
|
}
|
|
`);
|
|
editor.getDoc().head.appendChild(style);
|
|
|
|
return () => {
|
|
style.remove();
|
|
};
|
|
}, [editor, themeId]);
|
|
};
|
|
|
|
const useHighlighter = (editor: Editor, searchRegexes: RegExp[]) => {
|
|
return useMemo(() => {
|
|
if (!editor) {
|
|
return { canHighlight: false };
|
|
}
|
|
|
|
const editorWindow = editor.getWin();
|
|
const ranges: Map<Node, Range[]> = new Map();
|
|
let highlight: Highlight = undefined;
|
|
|
|
const processNode = (node: Node) => {
|
|
ranges.delete(node);
|
|
|
|
type FoundChildAndOffset = { node: Node; offset: number };
|
|
const findChildAtTextOffset = (parent: Node, offset: number): FoundChildAndOffset => {
|
|
if (offset > parent.textContent.length) return null;
|
|
if (parent.nodeName === '#text') {
|
|
return { node: parent, offset };
|
|
}
|
|
|
|
let start = 0;
|
|
for (const child of parent.childNodes) {
|
|
if (child.nodeName === '#comment') continue;
|
|
|
|
const found = findChildAtTextOffset(child, offset - start);
|
|
start += child.textContent.length;
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const buildRange = (parent: Node, startIndex: number, endIndex: number) => {
|
|
const range: Range = new editorWindow.Range();
|
|
const start = findChildAtTextOffset(parent, startIndex);
|
|
const end = findChildAtTextOffset(parent, endIndex);
|
|
range.setStart(start?.node ?? parent, start?.offset ?? 0);
|
|
range.setEnd(end?.node ?? parent, end?.offset ?? parent.textContent.length);
|
|
return range;
|
|
};
|
|
|
|
// Process highlights at the paragraph level, where possible, to pick up formatting that crosses Markdown boundaries
|
|
const isLeaf = node.nodeName === '#text' || node.nodeName === 'P';
|
|
if (isLeaf) {
|
|
const childRanges = [];
|
|
for (const term of searchRegexes) {
|
|
const matches = node.textContent.matchAll(term);
|
|
|
|
for (const match of matches) {
|
|
const startIndex = match.index ?? 0;
|
|
const endIndex = (match.index ?? 0) + match[0].length;
|
|
if (startIndex !== endIndex) {
|
|
childRanges.push(buildRange(node, startIndex, endIndex));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (childRanges.length > 0) {
|
|
ranges.set(node, childRanges);
|
|
}
|
|
} else {
|
|
for (const child of node.childNodes) {
|
|
processNode(child);
|
|
}
|
|
}
|
|
};
|
|
|
|
const closestParagraph = (node: Node) => node.parentElement?.closest('p');
|
|
|
|
const onNodeChanged = (node: Node) => {
|
|
highlight?.clear();
|
|
|
|
processNode(closestParagraph(node) ?? node);
|
|
|
|
highlight = new editorWindow.Highlight(...[...ranges.values()].flat());
|
|
editorWindow.CSS.highlights.set(searchHighlightKey, highlight);
|
|
};
|
|
|
|
const onNodeRemoved = (node: Node, parent: Node) => {
|
|
const removeFromRanges = (node: Node) => {
|
|
ranges.delete(node);
|
|
for (const child of node.childNodes) {
|
|
removeFromRanges(child);
|
|
}
|
|
};
|
|
removeFromRanges(node);
|
|
|
|
// Handle the case where removing a node created a match:
|
|
onNodeChanged(closestParagraph(parent) ?? parent);
|
|
};
|
|
|
|
const clearHighlights = () => {
|
|
highlight?.clear();
|
|
editorWindow.CSS.highlights.delete(searchHighlightKey);
|
|
ranges.clear();
|
|
};
|
|
|
|
return { onNodeChanged, onNodeRemoved, clearHighlights, canHighlight: searchRegexes.length > 0 };
|
|
}, [editor, searchRegexes]);
|
|
};
|
|
|
|
const useHighlightedSearchTerms = (editor: Editor, searchTerms: HighlightedWord[], themeId: number) => {
|
|
const searchRegexes = useSearchRegexes(searchTerms);
|
|
useHighlightStyleSheet(editor, themeId);
|
|
const highlighter = useHighlighter(editor, searchRegexes);
|
|
|
|
useEffect(() => {
|
|
if (!editor || !highlighter.canHighlight) {
|
|
return () => {};
|
|
}
|
|
|
|
const editorBody = editor.getBody();
|
|
|
|
type NodeChangeEvent = { element: Element };
|
|
const onNodeChange = ({ element }: EditorEvent<NodeChangeEvent>) => {
|
|
highlighter.onNodeChanged(element);
|
|
};
|
|
|
|
const onKeyUp = (_event: KeyboardEvent) => {
|
|
// Use selectedNode and not event.target -- event.target seems to always point
|
|
// to the body.
|
|
const selectedNode = editor.selection.getNode();
|
|
if (selectedNode) {
|
|
highlighter.onNodeChanged(selectedNode);
|
|
}
|
|
};
|
|
|
|
const onSetContent = () => {
|
|
highlighter.onNodeChanged(editorBody);
|
|
};
|
|
|
|
editor.on('NodeChange', onNodeChange);
|
|
editor.on('SetContent', onSetContent);
|
|
|
|
// NodeChange doesn't fire while typing, so we also need keyup
|
|
editor.on('keyup', onKeyUp);
|
|
|
|
// NodeChange also doesn't fire for certain DOM changes (e.g. when TinyMCE-provided search highlights are added/removed):
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
for (const node of mutation.removedNodes) {
|
|
highlighter.onNodeRemoved(node, mutation.target);
|
|
}
|
|
|
|
for (const node of mutation.addedNodes) {
|
|
highlighter.onNodeChanged(node);
|
|
}
|
|
}
|
|
});
|
|
observer.observe(editorBody, { subtree: true, childList: true });
|
|
|
|
onSetContent();
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
highlighter.clearHighlights();
|
|
|
|
editor.off('NodeChange', onNodeChange);
|
|
editor.off('keyup', onKeyUp);
|
|
editor.off('SetContent', onSetContent);
|
|
};
|
|
}, [editor, highlighter]);
|
|
};
|
|
|
|
export default useHighlightedSearchTerms;
|