PPTist/src/hooks/useSearch.ts
2024-01-03 19:56:16 +08:00

444 lines
14 KiB
TypeScript

import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { PPTTableElement } from '@/types/slides'
import message from '@/utils/message'
interface SearchTextResult {
elType: 'text' | 'shape'
slideId: string
elId: string
}
interface SearchTableResult {
elType: 'table'
slideId: string
elId: string
cellIndex: [number, number]
}
type SearchResult = SearchTextResult | SearchTableResult
type Modifiers = 'g' | 'gi'
export default () => {
const mainStore = useMainStore()
const slidesStore = useSlidesStore()
const { handleElement } = storeToRefs(mainStore)
const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
const searchWord = ref('')
const replaceWord = ref('')
const searchResults = ref<SearchResult[]>([])
const searchIndex = ref(-1)
const modifiers = ref<Modifiers>('g')
const search = () => {
const textList: SearchResult[] = []
const matchRegex = new RegExp(searchWord.value, modifiers.value)
const textRegex = /(<([^>]+)>)/g
for (const slide of slides.value) {
for (const el of slide.elements) {
if (el.type === 'text') {
const text = el.content.replace(textRegex, '')
const rets = text.match(matchRegex)
rets && textList.push(...new Array(rets.length).fill({
slideId: slide.id,
elId: el.id,
elType: el.type,
}))
}
else if (el.type === 'shape' && el.text && el.text.content) {
const text = el.text.content.replace(textRegex, '')
const rets = text.match(matchRegex)
rets && textList.push(...new Array(rets.length).fill({
slideId: slide.id,
elId: el.id,
elType: el.type,
}))
}
else if (el.type === 'table') {
for (let i = 0; i < el.data.length; i++) {
const row = el.data[i]
for (let j = 0; j < row.length; j++) {
const cell = row[j]
if (!cell.text) continue
const text = cell.text.replace(textRegex, '')
const rets = text.match(matchRegex)
rets && textList.push(...new Array(rets.length).fill({
slideId: slide.id,
elId: el.id,
elType: el.type,
cellIndex: [i, j],
}))
}
}
}
}
}
if (textList.length) {
searchResults.value = textList
searchIndex.value = 0
highlightCurrentSlide()
}
else {
message.warning('未查找到匹配项')
clearMarks()
}
}
const getTextNodeList = (dom: Node): Text[] => {
const nodeList = [...dom.childNodes]
const textNodes = []
while (nodeList.length) {
const node = nodeList.shift()!
if (node.nodeType === node.TEXT_NODE) {
(node as Text).wholeText && textNodes.push(node as Text)
}
else {
nodeList.unshift(...node.childNodes)
}
}
return textNodes
}
const getTextInfoList = (textNodes: Text[]) => {
let length = 0
const textList = textNodes.map(node => {
const startIdx = length, endIdx = length + node.wholeText.length
length = endIdx
return {
text: node.wholeText,
startIdx,
endIdx
}
})
return textList
}
type TextInfoList = ReturnType<typeof getTextInfoList>
const getMatchList = (content: string, keyword: string) => {
const reg = new RegExp(keyword, modifiers.value)
const matchList = []
let match = reg.exec(content)
while (match) {
matchList.push(match)
match = reg.exec(content)
}
return matchList
}
const highlight = (textNodes: Text[], textList: TextInfoList, matchList: RegExpExecArray[], index: number) => {
for (let i = matchList.length - 1; i >= 0; i--) {
const match = matchList[i]
const matchStart = match.index
const matchEnd = matchStart + match[0].length
for (let textIdx = 0; textIdx < textList.length; textIdx++) {
const { text, startIdx, endIdx } = textList[textIdx]
if (endIdx < matchStart) continue
if (startIdx >= matchEnd) break
let textNode = textNodes[textIdx]
const nodeMatchStartIdx = Math.max(0, matchStart - startIdx)
const nodeMatchLength = Math.min(endIdx, matchEnd) - startIdx - nodeMatchStartIdx
if (nodeMatchStartIdx > 0) textNode = textNode.splitText(nodeMatchStartIdx)
if (nodeMatchLength < textNode.wholeText.length) textNode.splitText(nodeMatchLength)
const mark = document.createElement('mark')
mark.dataset.index = index + i + ''
mark.innerText = text.substring(nodeMatchStartIdx, nodeMatchStartIdx + nodeMatchLength)
textNode.parentNode!.replaceChild(mark, textNode)
}
}
}
const highlightTableText = (nodes: NodeListOf<Element>, index: number) => {
for (const node of nodes) {
node.innerHTML = node.innerHTML.replace(new RegExp(searchWord.value, modifiers.value), () => {
return `<mark data-index=${index++}>${searchWord.value}</mark>`
})
}
}
const clearMarks = () => {
const markNodes = document.querySelectorAll('.editable-element mark')
for (const mark of markNodes) {
setTimeout(() => {
const parentNode = mark.parentNode!
const text = mark.textContent!
parentNode.replaceChild(document.createTextNode(text), mark)
}, 0)
}
}
const highlightCurrentSlide = () => {
clearMarks()
setTimeout(() => {
for (let i = 0; i < searchResults.value.length; i++) {
const lastTarget = searchResults.value[i - 1]
const target = searchResults.value[i]
if (target.slideId !== currentSlide.value.id) continue
if (lastTarget && lastTarget.elId === target.elId) continue
const node = document.querySelector(`#editable-element-${target.elId}`)
if (node) {
if (target.elType === 'table') {
const cells = node.querySelectorAll('.cell-text')
highlightTableText(cells, i)
}
else {
const textNodes = getTextNodeList(node)
const textList = getTextInfoList(textNodes)
const content = textList.map(({ text }) => text).join('')
const matchList = getMatchList(content, searchWord.value)
highlight(textNodes, textList, matchList, i)
}
}
}
}, 0)
}
const setActiveMark = () => {
const markNodes = document.querySelectorAll('mark[data-index]')
for (const node of markNodes) {
setTimeout(() => {
const index = (node as HTMLElement).dataset.index
if (index !== undefined && +index === searchIndex.value) {
node.classList.add('active')
}
else node.classList.remove('active')
}, 0)
}
}
const turnTarget = () => {
if (searchIndex.value === -1) return
const target = searchResults.value[searchIndex.value]
if (target.slideId === currentSlide.value.id) setTimeout(setActiveMark, 0)
else {
const index = slides.value.findIndex(slide => slide.id === target.slideId)
if (index !== -1) slidesStore.updateSlideIndex(index)
}
}
const searchNext = () => {
if (!searchWord.value) return message.warning('请先输入查找内容')
mainStore.setActiveElementIdList([])
if (searchIndex.value === -1) search()
else if (searchIndex.value < searchResults.value.length - 1) searchIndex.value += 1
else searchIndex.value = 0
turnTarget()
}
const searchPrev = () => {
if (!searchWord.value) return message.warning('请先输入查找内容')
mainStore.setActiveElementIdList([])
if (searchIndex.value === -1) search()
else if (searchIndex.value > 0) searchIndex.value -= 1
else searchIndex.value = searchResults.value.length - 1
turnTarget()
}
const replace = () => {
if (!searchWord.value) return
if (searchIndex.value === -1) {
searchNext()
return
}
const target = searchResults.value[searchIndex.value]
let targetElement = null
if (target.elType === 'table') {
const [i, j] = target.cellIndex
targetElement = document.querySelector(`#editable-element-${target.elId} .cell[data-cell-index="${i}_${j}"] .cell-text`)
}
else targetElement = document.querySelector(`#editable-element-${target.elId} .ProseMirror`)
if (!targetElement) return
const fakeElement = document.createElement('div')
fakeElement.innerHTML = targetElement.innerHTML
let replaced = false
const marks = fakeElement.querySelectorAll('mark[data-index]')
for (const mark of marks) {
const parentNode = mark.parentNode!
if (mark.classList.contains('active')) {
if (replaced) parentNode.removeChild(mark)
else {
parentNode.replaceChild(document.createTextNode(replaceWord.value), mark)
replaced = true
}
}
else {
const text = mark.textContent!
parentNode.replaceChild(document.createTextNode(text), mark)
}
}
if (target.elType === 'text') {
const props = { content: fakeElement.innerHTML }
slidesStore.updateElement({ id: target.elId, props })
}
else if (target.elType === 'shape') {
const el = currentSlide.value.elements.find(item => item.id === target.elId)
if (el && el.type === 'shape' && el.text) {
const props = { text: { ...el.text, content: fakeElement.innerHTML } }
slidesStore.updateElement({ id: target.elId, props })
}
}
else if (target.elType === 'table') {
const el = currentSlide.value.elements.find(item => item.id === target.elId)
if (el && el.type === 'table') {
const data = el.data.map((row, i) => {
if (i === target.cellIndex[0]) {
return row.map((cell, j) => {
if (j === target.cellIndex[1]) {
return {
...cell,
text: fakeElement.innerHTML,
}
}
return cell
})
}
return row
})
const props = { data }
slidesStore.updateElement({ id: target.elId, props })
}
}
searchResults.value.splice(searchIndex.value, 1)
if (searchResults.value.length) {
if (searchIndex.value > searchResults.value.length - 1) {
searchIndex.value = 0
}
nextTick(() => {
highlightCurrentSlide()
turnTarget()
})
}
else searchIndex.value = -1
}
const replaceAll = () => {
if (!searchWord.value) return
if (searchIndex.value === -1) {
searchNext()
return
}
for (let i = 0; i < searchResults.value.length; i++) {
const lastTarget = searchResults.value[i - 1]
const target = searchResults.value[i]
if (lastTarget && lastTarget.elId === target.elId) continue
const targetSlide = slides.value.find(item => item.id === target.slideId)
if (!targetSlide) continue
const targetElement = targetSlide.elements.find(item => item.id === target.elId)
if (!targetElement) continue
const fakeElement = document.createElement('div')
if (targetElement.type === 'text') fakeElement.innerHTML = targetElement.content
else if (targetElement.type === 'shape') fakeElement.innerHTML = targetElement.text?.content || ''
if (target.elType === 'table') {
const data = (targetElement as PPTTableElement).data.map(row => {
return row.map(cell => {
if (!cell.text) return cell
return {
...cell,
text: cell.text.replace(new RegExp(searchWord.value, 'g'), replaceWord.value),
}
})
})
const props = { data }
slidesStore.updateElement({ id: target.elId, slideId: target.slideId, props })
}
else {
const textNodes = getTextNodeList(fakeElement)
const textList = getTextInfoList(textNodes)
const content = textList.map(({ text }) => text).join('')
const matchList = getMatchList(content, searchWord.value)
highlight(textNodes, textList, matchList, i)
const marks = fakeElement.querySelectorAll('mark[data-index]')
let lastMarkIndex = -1
for (const mark of marks) {
const markIndex = +(mark as HTMLElement).dataset.index!
const parentNode = mark.parentNode!
if (markIndex === lastMarkIndex) parentNode.removeChild(mark)
else {
parentNode.replaceChild(document.createTextNode(replaceWord.value), mark)
lastMarkIndex = markIndex
}
}
if (target.elType === 'text') {
const props = { content: fakeElement.innerHTML }
slidesStore.updateElement({ id: target.elId, slideId: target.slideId, props })
}
else if (target.elType === 'shape') {
const el = currentSlide.value.elements.find(item => item.id === target.elId)
if (el && el.type === 'shape' && el.text) {
const props = { text: { ...el.text, content: fakeElement.innerHTML } }
slidesStore.updateElement({ id: target.elId, slideId: target.slideId, props })
}
}
}
}
searchResults.value = []
searchIndex.value = -1
}
const reset = () => {
searchIndex.value = -1
searchResults.value = []
if (!searchWord.value) clearMarks()
}
watch(searchWord, reset)
watch(slideIndex, () => {
nextTick(() => {
highlightCurrentSlide()
setTimeout(setActiveMark, 0)
})
})
watch(handleElement, () => {
if (handleElement.value) {
searchIndex.value = -1
searchResults.value = []
clearMarks()
}
})
onBeforeUnmount(clearMarks)
const toggleModifiers = () => {
modifiers.value = modifiers.value === 'g' ? 'gi' : 'g'
reset()
}
return {
searchWord,
replaceWord,
searchResults,
searchIndex,
modifiers,
searchNext,
searchPrev,
replace,
replaceAll,
toggleModifiers,
}
}