Created
September 18, 2025 21:13
-
-
Save stow1x/266f46dbba750304e7f5bd7f7a97df8c to your computer and use it in GitHub Desktop.
Vue.js composable based on TreeWalker and Highlight API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* eslint-disable @typescript-eslint/no-explicit-any */ | |
| import type { Ref } from 'vue'; | |
| import { onBeforeUnmount } from 'vue'; | |
| export interface UseKeywordsHighlighterOptions { | |
| selector?: | |
| | string | |
| | Element | |
| | Node | |
| | (Element | Node)[] | |
| | NodeListOf<Element | ChildNode> | |
| | null | |
| | undefined; | |
| source?: Ref<string | string[] | null | undefined>; | |
| caseSensitive?: boolean; | |
| wholeWord?: boolean; | |
| highlightId?: string; | |
| } | |
| export interface UseKeywordsHighlighterReturn { | |
| mark: (value: string | string[] | null | undefined) => void; | |
| unmark: () => void; | |
| isSupported: boolean; | |
| highlightName: string; | |
| } | |
| const isHighlightSupported = (): boolean => { | |
| try { | |
| return typeof CSS !== 'undefined' && 'highlights' in CSS && !!(CSS as any).highlights; | |
| } catch { | |
| return false; | |
| } | |
| }; | |
| const normalizeContainers = (selector: UseKeywordsHighlighterOptions['selector']): Element[] => { | |
| if (typeof document === 'undefined') { | |
| return []; | |
| } | |
| if (!selector) { | |
| return document.body ? [document.body] : []; | |
| } | |
| if (typeof selector === 'string') { | |
| return Array.from(document.querySelectorAll(selector)); | |
| } | |
| if (selector instanceof Element) { | |
| return [selector]; | |
| } | |
| if ((selector as Node)?.nodeType === Node.ELEMENT_NODE) { | |
| return [selector as Element]; | |
| } | |
| if (Array.isArray(selector)) { | |
| return (selector as (Element | Node)[]) | |
| .map((n) => (n instanceof Element ? n : (n as Element))) | |
| .filter(Boolean); | |
| } | |
| if ((selector as NodeListOf<Element | ChildNode>)?.forEach) { | |
| return Array.from(selector as NodeListOf<Element | ChildNode>).filter( | |
| (n): n is Element => n instanceof Element, | |
| ); | |
| } | |
| return document.body ? [document.body] : []; | |
| }; | |
| const collectTextNodes = (containers: Element[]): Text[] => { | |
| const texts: Text[] = []; | |
| for (const root of containers) { | |
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); | |
| let curr = walker.nextNode(); | |
| while (curr) { | |
| const t = curr as Text; | |
| if (t.data && t.data.trim().length) { | |
| texts.push(t); | |
| } | |
| curr = walker.nextNode(); | |
| } | |
| } | |
| return texts; | |
| }; | |
| const toTerms = (value: string | string[] | null | undefined): string[] => { | |
| if (!value) { | |
| return []; | |
| } | |
| const raw = Array.isArray(value) ? value : value.split(/\s+/g); | |
| return raw.map((t) => t.trim()).filter(Boolean); | |
| }; | |
| const buildMatcher = (terms: string[], caseSensitive: boolean, wholeWord: boolean) => { | |
| if (!terms.length) { | |
| return null as RegExp | null; | |
| } | |
| const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); | |
| const pattern = wholeWord ? `\\b(?:${escaped.join('|')})\\b` : `(?:${escaped.join('|')})`; | |
| return new RegExp(pattern, caseSensitive ? 'g' : 'gi'); | |
| }; | |
| export const useKeywordsHighlighter = ( | |
| options: UseKeywordsHighlighterOptions = { | |
| }, | |
| ): UseKeywordsHighlighterReturn => { | |
| const { | |
| selector = undefined, | |
| caseSensitive = false, | |
| wholeWord = false, | |
| highlightId, | |
| } = options; | |
| const isSupported = isHighlightSupported(); | |
| const highlightName = highlightId || 'keywords-highlighter'; | |
| const unmark = () => { | |
| if (!isSupported) { | |
| return; | |
| } | |
| (CSS as any).highlights.delete(highlightName); | |
| }; | |
| const mark = async (value: string | string[] | null | undefined) => { | |
| if (!isSupported) { | |
| return; | |
| } | |
| if (typeof document === 'undefined') { | |
| return; | |
| } | |
| const containers = normalizeContainers(selector); | |
| const terms = toTerms(value); | |
| if (!terms.length) { | |
| unmark(); | |
| return; | |
| } | |
| const matcher = buildMatcher(terms, caseSensitive, wholeWord); | |
| if (!matcher) { | |
| unmark(); | |
| return; | |
| } | |
| const textNodes = collectTextNodes(containers); | |
| const ranges: Range[] = []; | |
| for (const node of textNodes) { | |
| const text = node.data; | |
| matcher.lastIndex = 0; | |
| let m: RegExpExecArray | null; | |
| while ((m = matcher.exec(text)) !== null) { | |
| const start = m.index; | |
| const end = start + m[0].length; | |
| const r = new Range(); | |
| r.setStart(node, start); | |
| r.setEnd(node, end); | |
| ranges.push(r); | |
| if (matcher.lastIndex === m.index) { | |
| matcher.lastIndex++; | |
| } | |
| } | |
| } | |
| const HighlightCtor = (globalThis as any).Highlight || (window as any)?.Highlight; | |
| if (!HighlightCtor) { | |
| return; | |
| } | |
| const highlight = new HighlightCtor(...ranges); | |
| (CSS as any).highlights.set(highlightName, highlight); | |
| }; | |
| onBeforeUnmount(() => { | |
| unmark(); | |
| }); | |
| return { | |
| mark, | |
| unmark, | |
| isSupported, | |
| highlightName, | |
| }; | |
| }; | |
| export default useKeywordsHighlighter; | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment