| 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, | |
| } | |
| } |