feat: 支持查找/替换(#201)

This commit is contained in:
pipipi-pikachu 2023-09-05 20:10:00 +08:00
parent bced3b889c
commit 1595c0c70e
14 changed files with 647 additions and 10 deletions

View File

@ -66,6 +66,7 @@ npm run serve
- 翻页动画
- 元素动画(入场、退场、强调)
- 选择面板(隐藏元素、层级排序、元素命名)
- 查找/替换
### 幻灯片元素编辑
- 元素添加、删除
- 元素复制粘贴

View File

@ -6,7 +6,6 @@
colorText: '#41464b',
borderRadius: 2,
fontSize: 13,
lineHeight: 1.5715,
},
}"
>

View File

@ -75,6 +75,7 @@ table {
a {
text-decoration: none;
color: $themeColor;
}
img {
@ -88,6 +89,10 @@ hr {
overflow: visible;
}
mark.active {
background-color: #ff9632;
}
input,
button,
select,

View File

@ -1,9 +1,10 @@
<template>
<div
class="moveable-panel"
ref="moveablePanelRef"
:style="{
width: width + 'px',
height: height + 'px',
height: height ? height + 'px' : 'auto',
left: x + 'px',
top: y + 'px',
}"
@ -26,7 +27,7 @@
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
const props = withDefaults(defineProps<{
width: number
@ -48,13 +49,20 @@ const emit = defineEmits<{
const x = ref(0)
const y = ref(0)
const moveablePanelRef = ref<HTMLElement>()
const realHeight = computed(() => {
if (!props.height) {
return moveablePanelRef.value?.clientHeight || 0
}
return props.height
})
onMounted(() => {
if (props.left >= 0) x.value = props.left
else x.value = document.body.clientWidth + props.left - props.width
if (props.top >= 0) y.value = props.top
else y.value = document.body.clientHeight + props.top - props.height
else y.value = document.body.clientHeight + props.top - realHeight.value
})
const startMove = (e: MouseEvent) => {
@ -83,7 +91,7 @@ const startMove = (e: MouseEvent) => {
if (left < 0) left = 0
if (top < 0) top = 0
if (left + props.width > windowWidth) left = windowWidth - props.width
if (top + props.height > clientHeight) top = clientHeight - props.height
if (top + realHeight.value > clientHeight) top = clientHeight - realHeight.value
x.value = left
y.value = top

View File

@ -151,6 +151,11 @@ export default () => {
enterScreeningFromStart()
return
}
if (key === KEYS.F) {
e.preventDefault()
mainStore.setSearchPanelState(true)
return
}
if (!editorAreaFocus.value && !thumbnailsFocus.value) return

431
src/hooks/useSearch.ts Normal file
View File

@ -0,0 +1,431 @@
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 'ant-design-vue'
interface SearchTextResult {
elType: 'text' | 'shape'
slideId: string
elId: string
}
interface SearchTableResult {
elType: 'table'
slideId: string
elId: string
cellIndex: [number, number]
}
type SearchResult = SearchTextResult | SearchTableResult
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 search = () => {
const textList: SearchResult[] = []
const matchRegex = new RegExp(searchWord.value, 'g')
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, 'g')
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, 'g'), () => {
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.replaceAll(searchWord.value, 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
}
watch(searchWord, () => {
searchIndex.value = -1
searchResults.value = []
if (!searchWord.value) clearMarks()
})
watch(slideIndex, () => {
nextTick(() => {
highlightCurrentSlide()
setTimeout(setActiveMark, 0)
})
})
watch(handleElement, () => {
if (handleElement.value) {
searchIndex.value = -1
searchResults.value = []
clearMarks()
}
})
onBeforeUnmount(clearMarks)
return {
searchWord,
replaceWord,
searchResults,
searchIndex,
searchNext,
searchPrev,
replace,
replaceAll,
}
}

View File

@ -113,6 +113,9 @@ import {
PreviewOpen,
PreviewClose,
StopwatchStart,
Search,
Left,
Right,
} from '@icon-park/vue-next'
interface Icons {
@ -231,6 +234,9 @@ export const icons: Icons = {
IconPreviewOpen: PreviewOpen,
IconPreviewClose: PreviewClose,
IconStopwatchStart: StopwatchStart,
IconSearch: Search,
IconLeft: Left,
IconRight: Right,
}
export default {

View File

@ -35,6 +35,7 @@ export interface MainState {
databaseId: string
textFormatPainter: TextFormatPainter | null
showSelectPanel: boolean
showSearchPanel: boolean
}
const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
@ -67,6 +68,7 @@ export const useMainStore = defineStore('main', {
databaseId, // 标识当前应用的indexedDB数据库ID
textFormatPainter: null, // 文字格式刷
showSelectPanel: false, // 打开选择面板
showSearchPanel: false, // 打开查找替换面板
}),
getters: {
@ -184,5 +186,9 @@ export const useMainStore = defineStore('main', {
setSelectPanelState(show: boolean) {
this.showSelectPanel = show
},
setSearchPanelState(show: boolean) {
this.showSearchPanel = show
},
},
})

View File

@ -14,6 +14,7 @@ interface RemoveElementPropData {
interface UpdateElementData {
id: string | string[]
props: Partial<PPTElement>
slideId?: string
}
interface FormatedAnimation {
@ -164,10 +165,10 @@ export const useSlidesStore = defineStore('slides', {
},
updateElement(data: UpdateElementData) {
const { id, props } = data
const { id, props, slideId } = data
const elIdList = typeof id === 'string' ? [id] : id
const slideIndex = this.slideIndex
const slideIndex = slideId ? this.slides.findIndex(item => item.id === slideId) : this.slideIndex
const slide = this.slides[slideIndex]
const elements = slide.elements.map(el => {
return elIdList.includes(el.id) ? { ...el, ...props } : el

View File

@ -157,6 +157,22 @@ const link: MarkSpec = {
toDOM: node => ['a', node.attrs, 0],
}
const mark: MarkSpec = {
attrs: {
index: { default: null },
},
parseDOM: [
{
tag: 'mark',
getAttrs: dom => {
const index = (dom as HTMLElement).dataset.index
return { index }
}
},
],
toDOM: node => ['mark', { 'data-index': node.attrs.index }, 0],
}
const { em, strong, code } = marks
export default {
@ -172,4 +188,5 @@ export default {
strikethrough,
underline,
link,
mark,
}

View File

@ -7,6 +7,10 @@
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="重做">
<IconNext class="handler-item" :class="{ 'disable': !canRedo }" @click="redo()" />
</Tooltip>
<Divider type="vertical" style="height: 20px;" />
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="查找/替换" @click="openSraechPanel()">
<IconSearch class="handler-item" />
</Tooltip>
</div>
<div class="add-element-handler">
@ -138,6 +142,7 @@ import FileInput from '@/components/FileInput.vue'
import {
Tooltip,
Popover,
Divider,
Modal,
} from 'ant-design-vue'
@ -215,6 +220,11 @@ const drawLine = (line: LinePoolItem) => {
})
linePoolVisible.value = false
}
//
const openSraechPanel = () => {
mainStore.setSearchPanelState(true)
}
</script>
<style lang="scss" scoped>

View File

@ -0,0 +1,146 @@
<template>
<MoveablePanel
class="search-panel"
:width="300"
:height="0"
:left="-270"
:top="90"
>
<div class="close-btn" @click="close()" @mousedown.stop><IconClose /></div>
<div class="tabs">
<div
class="tab"
:class="{ 'active': type === tab.key }"
v-for="tab in tabs"
:key="tab.key"
@click="type = tab.key"
@mousedown.stop
>{{tab.label}}</div>
</div>
<div class="content" :class="type" @mousedown.stop>
<Input class="input" v-model:value="searchWord" placeholder="输入查找内容" @keydown.enter="searchNext()" ref="searchInpRef">
<template #suffix>
<span class="count">{{searchIndex + 1}}/{{searchResults.length}}</span>
<Divider type="vertical" />
<IconLeft class="next-btn left" @click="searchPrev()" />
<IconRight class="next-btn right" @click="searchNext()" />
</template>
</Input>
<Input class="input" v-model:value="replaceWord" placeholder="输入替换内容" @keydown.enter="replace()" v-if="type === 'replace'"></Input>
<div class="footer" v-if="type === 'replace'">
<Button :disabled="!searchWord" style="margin-left: 5px;" @click="replace()">替换</Button>
<Button :disabled="!searchWord" type="primary" style="margin-left: 5px;" @click="replaceAll()">全部替换</Button>
</div>
</div>
</MoveablePanel>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useMainStore } from '@/store'
import useSearch from '@/hooks/useSearch'
import MoveablePanel from '@/components/MoveablePanel.vue'
import {
Button,
Input,
Divider,
} from 'ant-design-vue'
type TypeKey = 'search' | 'replace'
interface TabItem {
key: TypeKey
label: string
}
const mainStore = useMainStore()
const {
searchWord,
replaceWord,
searchResults,
searchIndex,
searchNext,
searchPrev,
replace,
replaceAll,
} = useSearch()
const type = ref<TypeKey>('search')
const tabs: TabItem[] = [
{ key: 'search', label: '查找' },
{ key: 'replace', label: '替换' },
]
const close = () => {
mainStore.setSearchPanelState(false)
}
</script>
<style lang="scss" scoped>
.search-panel {
font-size: 13px;
}
.tabs {
display: flex;
justify-content: flex-start;
align-items: center;
border-bottom: 1px solid $borderColor;
line-height: 1.5;
user-select: none;
}
.tab {
padding: 0 10px 8px;
border-bottom: 2px solid transparent;
cursor: pointer;
&.active {
border-bottom: 2px solid $themeColor;
}
}
.content {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.input {
margin-top: 10px;
}
.count {
font-size: 12px;
user-select: none;
}
.next-btn {
width: 22px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin: 0 !important;
user-select: none;
cursor: pointer;
&:hover {
color: $themeColor;
}
}
.footer {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 10px;
}
.close-btn {
width: 40px;
height: 40px;
position: absolute;
top: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
color: #666;
font-size: 12px;
cursor: pointer;
}
</style>

View File

@ -17,6 +17,7 @@
</div>
<SelectPanel v-if="showSelectPanel" />
<SearchPanel v-if="showSearchPanel" />
<Modal
:open="!!dialogForExport"
@ -46,10 +47,11 @@ import Toolbar from './Toolbar/index.vue'
import Remark from './Remark/index.vue'
import ExportDialog from './ExportDialog/index.vue'
import SelectPanel from './SelectPanel.vue'
import SearchPanel from './SearchPanel.vue'
import { Modal } from 'ant-design-vue'
const mainStore = useMainStore()
const { dialogForExport, showSelectPanel } = storeToRefs(mainStore)
const { dialogForExport, showSelectPanel, showSearchPanel } = storeToRefs(mainStore)
const closeExportDialog = () => mainStore.setDialogForExport('')
const remarkHeight = ref(40)

View File

@ -38,7 +38,7 @@
@mousedown="$event => handleSelectElement($event)"
@touchstart="$event => handleSelectElement($event)"
>
<div class="mask-tip" :style="{ transform: `scale(${ 1 / canvasScale })` }">双击编辑</div>
<div class="mask-tip" v-if="handleElementId === elementInfo.id" :style="{ transform: `scale(${ 1 / canvasScale })` }">双击编辑</div>
</div>
</div>
</div>