InstantSearch = {
const StateTransition = {
empty: 0,
valid: 1,
match: 2
}
class InstantSearch {
constructor(
root,
token,
scrollToResult = true,
defaultClassName = "highlight",
defaultCaseSensitive = false,
) {
this.state = {}
this.root = root
this.token = token
this.scrollToResult = scrollToResult
this.defaultClassName = defaultClassName
this.defaultCaseSensitive = defaultCaseSensitive
this.matches = []
this.perfs = []
}
highlight() {
this.matches = []
this.state[this.token.text] = {}
if (this.token.text.length > 0) {
const t1 = performance.now()
this.walk(this.root)
const t2 = performance.now()
this.perfs.push({event: "Search text", time: t2 - t1})
const t3 = performance.now()
this.matches.reverse().forEach(m => {
const className = m.token.className || this.defaultClassName
const range = this.createRange(m.startNode, m.startOffset, m.endNode, m.endOffset)
this.wrapRange(range, className, m.startNode, m.endNode)
})
const t4 = performance.now()
this.perfs.push({event: "Highlight text", time: t4 - t3})
}
}
removeHighlight() {
const t1 = performance.now()
let element
if (this.root instanceof Element) {
element = this.root
} else if (this.root.parentElement) {
element = this.root.parentElement
}
element?.querySelectorAll(
`.${this.token.className || this.defaultClassName}`
).forEach(
el => {
const fragment = document.createDocumentFragment()
const childNodes = el.childNodes
fragment.append(...Array.from(childNodes))
const parent = el.parentNode
parent?.replaceChild(fragment, el)
parent?.normalize()
this.mergeAdjacentSimilarNodes(parent)
}
)
const t2 = performance.now()
this.perfs.push({event: "Remove highlights", time: t2 - t1})
}
mergeAdjacentSimilarNodes(parent) {
if (parent && parent.childNodes) {
Array.from(parent?.childNodes).reduce((acc, val) => {
if (val instanceof Element) {
if (acc && acc.tagName.toLowerCase() === val.tagName.toLowerCase()) {
acc.append(...Array.from(val.childNodes))
parent.removeChild(val)
acc && this.mergeAdjacentSimilarNodes(acc)
} else {
acc && this.mergeAdjacentSimilarNodes(acc)
acc = val
}
} else {
acc && this.mergeAdjacentSimilarNodes(acc)
acc = undefined
}
return acc
}, undefined)
}
}
search(node) {
const text = node.textContent
const token = this.token
const state = this.state[token.text]
const caseSensitive = token.caseSensitive || this.defaultCaseSensitive
const tokenStr = caseSensitive ? token.text : token.text.toLowerCase()
for (let i = 0; i < text.length; i++) {
const char = text[i]
const next = (
`${state.current || ""}${caseSensitive ? char : char.toLowerCase()}`
.replace(/\s+/g, " ")
)
if (next === tokenStr) {
this.transitionState(StateTransition.match, state, node, i, next)
} else {
const pos = tokenStr.indexOf(next)
if (pos === 0) {
this.transitionState(StateTransition.valid, state, node, i, next)
} else {
this.transitionState(StateTransition.empty, state, node, i, next)
}
}
}
}
transitionState(type, state, node, index, next) {
switch(type) {
case StateTransition.empty:
this.resetState(state)
break
case StateTransition.valid:
if (!state.current || state.current.length === 0) {
state.startNode = node
state.startOffset = index
}
state.current = next
break
case StateTransition.match: {
const isSingleChar = this.token.text.length === 1
const startNode = isSingleChar ? node : state.startNode
const startOffset = isSingleChar ? index : state.startOffset
this.matches.push({
token: this.token,
startNode,
startOffset,
endNode: node,
endOffset: index + 1
})
this.resetState(state)
break
}
default:
break
}
}
createRange(startNode, startOffset, endNode, endOffset) {
const range = new Range()
range.setStart(startNode, startOffset)
range.setEnd(endNode, endOffset )
return range
}
wrapRange(range, className, startNode, endNode) {
const clonedStartNode = startNode.cloneNode(true)
const clonedEndNode = endNode.cloneNode(true)
const selectedText = range.extractContents()
const marker = document.createElement("marker")
marker.classList.add(className)
marker.appendChild(selectedText)
range.insertNode(marker)
this.removeEmptyDirectSiblings(marker, clonedStartNode, clonedEndNode)
}
removeEmptyDirectSiblings(element, clonedStartNode, clonedEndNode) {
const remove = (element, originalNode) => {
let keepRemoving = true
while (keepRemoving) {
keepRemoving = this.removeEmptyElement( element, originalNode )
}
}
remove(element.previousElementSibling, clonedStartNode)
remove(element.nextElementSibling, clonedEndNode)
}
removeEmptyElement(element, originalNode) {
const isInOriginalNode = (element) => originalNode.childNodes
&& Array.from(originalNode.childNodes)
.some( (c) => (c instanceof Element) && c.outerHTML === element.outerHTML )
if (element) {
if (element.parentNode && !isInOriginalNode(element) && !element.textContent) {
element.parentNode.removeChild(element)
return true
} else if (element.childNodes[0] === element.children[0]) {
return this.removeEmptyElement(element.children[0], originalNode)
}
}
return false
}
resetState(state) {
delete state.current
delete state.startNode
delete state.startOffset
return state
}
walk(node) {
let currentParent = undefined
const treeWalker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT
)
while (treeWalker.nextNode()) {
const current = treeWalker.currentNode
if (current.parentElement) {
const parent = current.parentElement
const display = getComputedStyle(parent).display
if (
!["", "contents", "inline", "inline-block"].includes(display)
&& currentParent !== parent
) {
this.resetState(this.state[this.token.text])
currentParent = parent
}
}
this.search(current)
}
}
getResultsElements() {
const className = this.token.className || this.defaultClassName
const results = this.root.querySelectorAll(`.${className}`)
const active = this.root.querySelector(`.${className}.active`)
const activeIndex = Array.from(results).findIndex(el => el === active)
return {
results,
active,
activeIndex
}
}
switchSelected(active, next, results) {
const didOpenDetails = this.openDetailsAncestors(next)
if (didOpenDetails) {
this.resyncAnimations(results)
}
active?.classList.remove("active")
next?.classList.add("active")
if (this.scrollToResult) {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
console.log(entry)
if (entry.target === next && !entry.isIntersecting) {
console.log(entry.isIntersecting)
observer.unobserve(next)
observer.disconnect()
requestAnimationFrame(
() => next.scrollIntoView({block: "center", behavior: "smooth"})
)
} else {
observer.unobserve(entry.target)
}
}
})
if (next) {
observer.observe(next)
}
}
}
openDetailsAncestors(element) {
const detailsAncestors = this.getDetailsAncestors(element)
let didOpenDetails = false
detailsAncestors.forEach(d => {
if (!d.open && !d.children[0].contains(element)) {
d.open = true
didOpenDetails = true
}
})
return didOpenDetails
}
resyncAnimations(results) {
const className = this.token.className || this.defaultClassName
results.forEach(r => {
r.classList.remove(className)
requestAnimationFrame(() => r.classList.add(className))
})
}
selectNextResult() {
const {results, active, activeIndex} = this.getResultsElements()
const length = results.length
const index = (activeIndex + 1) % length
this.switchSelected(active, results[index], results)
}
selectPrevResult() {
const {results, active, activeIndex} = this.getResultsElements()
const length = results.length
const index = ((activeIndex > 0 ? activeIndex : length) - 1)
this.switchSelected(active, results[index], results)
}
getDetailsAncestors(element) {
const details = []
let current = element
while (current) {
if (
current instanceof HTMLDetailsElement
) {
details.push(current)
}
current = current.parentElement
}
return details
}
}
return InstantSearch
}