mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
feat: 支持查找/替换(#201)
This commit is contained in:
parent
bced3b889c
commit
1595c0c70e
@ -66,6 +66,7 @@ npm run serve
|
|||||||
- 翻页动画
|
- 翻页动画
|
||||||
- 元素动画(入场、退场、强调)
|
- 元素动画(入场、退场、强调)
|
||||||
- 选择面板(隐藏元素、层级排序、元素命名)
|
- 选择面板(隐藏元素、层级排序、元素命名)
|
||||||
|
- 查找/替换
|
||||||
### 幻灯片元素编辑
|
### 幻灯片元素编辑
|
||||||
- 元素添加、删除
|
- 元素添加、删除
|
||||||
- 元素复制粘贴
|
- 元素复制粘贴
|
||||||
|
@ -6,7 +6,6 @@
|
|||||||
colorText: '#41464b',
|
colorText: '#41464b',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineHeight: 1.5715,
|
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
@ -75,6 +75,7 @@ table {
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
color: $themeColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
@ -88,6 +89,10 @@ hr {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mark.active {
|
||||||
|
background-color: #ff9632;
|
||||||
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
button,
|
button,
|
||||||
select,
|
select,
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="moveable-panel"
|
class="moveable-panel"
|
||||||
|
ref="moveablePanelRef"
|
||||||
:style="{
|
:style="{
|
||||||
width: width + 'px',
|
width: width + 'px',
|
||||||
height: height + 'px',
|
height: height ? height + 'px' : 'auto',
|
||||||
left: x + 'px',
|
left: x + 'px',
|
||||||
top: y + 'px',
|
top: y + 'px',
|
||||||
}"
|
}"
|
||||||
@ -26,7 +27,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
width: number
|
width: number
|
||||||
@ -48,13 +49,20 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const x = ref(0)
|
const x = ref(0)
|
||||||
const y = 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(() => {
|
onMounted(() => {
|
||||||
if (props.left >= 0) x.value = props.left
|
if (props.left >= 0) x.value = props.left
|
||||||
else x.value = document.body.clientWidth + props.left - props.width
|
else x.value = document.body.clientWidth + props.left - props.width
|
||||||
|
|
||||||
if (props.top >= 0) y.value = props.top
|
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) => {
|
const startMove = (e: MouseEvent) => {
|
||||||
@ -83,7 +91,7 @@ const startMove = (e: MouseEvent) => {
|
|||||||
if (left < 0) left = 0
|
if (left < 0) left = 0
|
||||||
if (top < 0) top = 0
|
if (top < 0) top = 0
|
||||||
if (left + props.width > windowWidth) left = windowWidth - props.width
|
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
|
x.value = left
|
||||||
y.value = top
|
y.value = top
|
||||||
|
@ -151,6 +151,11 @@ export default () => {
|
|||||||
enterScreeningFromStart()
|
enterScreeningFromStart()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (key === KEYS.F) {
|
||||||
|
e.preventDefault()
|
||||||
|
mainStore.setSearchPanelState(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!editorAreaFocus.value && !thumbnailsFocus.value) return
|
if (!editorAreaFocus.value && !thumbnailsFocus.value) return
|
||||||
|
|
||||||
|
431
src/hooks/useSearch.ts
Normal file
431
src/hooks/useSearch.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
@ -113,6 +113,9 @@ import {
|
|||||||
PreviewOpen,
|
PreviewOpen,
|
||||||
PreviewClose,
|
PreviewClose,
|
||||||
StopwatchStart,
|
StopwatchStart,
|
||||||
|
Search,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
interface Icons {
|
interface Icons {
|
||||||
@ -231,6 +234,9 @@ export const icons: Icons = {
|
|||||||
IconPreviewOpen: PreviewOpen,
|
IconPreviewOpen: PreviewOpen,
|
||||||
IconPreviewClose: PreviewClose,
|
IconPreviewClose: PreviewClose,
|
||||||
IconStopwatchStart: StopwatchStart,
|
IconStopwatchStart: StopwatchStart,
|
||||||
|
IconSearch: Search,
|
||||||
|
IconLeft: Left,
|
||||||
|
IconRight: Right,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -35,6 +35,7 @@ export interface MainState {
|
|||||||
databaseId: string
|
databaseId: string
|
||||||
textFormatPainter: TextFormatPainter | null
|
textFormatPainter: TextFormatPainter | null
|
||||||
showSelectPanel: boolean
|
showSelectPanel: boolean
|
||||||
|
showSearchPanel: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
|
const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
|
||||||
@ -67,6 +68,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
databaseId, // 标识当前应用的indexedDB数据库ID
|
databaseId, // 标识当前应用的indexedDB数据库ID
|
||||||
textFormatPainter: null, // 文字格式刷
|
textFormatPainter: null, // 文字格式刷
|
||||||
showSelectPanel: false, // 打开选择面板
|
showSelectPanel: false, // 打开选择面板
|
||||||
|
showSearchPanel: false, // 打开查找替换面板
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@ -184,5 +186,9 @@ export const useMainStore = defineStore('main', {
|
|||||||
setSelectPanelState(show: boolean) {
|
setSelectPanelState(show: boolean) {
|
||||||
this.showSelectPanel = show
|
this.showSelectPanel = show
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSearchPanelState(show: boolean) {
|
||||||
|
this.showSearchPanel = show
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
@ -14,6 +14,7 @@ interface RemoveElementPropData {
|
|||||||
interface UpdateElementData {
|
interface UpdateElementData {
|
||||||
id: string | string[]
|
id: string | string[]
|
||||||
props: Partial<PPTElement>
|
props: Partial<PPTElement>
|
||||||
|
slideId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormatedAnimation {
|
interface FormatedAnimation {
|
||||||
@ -164,10 +165,10 @@ export const useSlidesStore = defineStore('slides', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateElement(data: UpdateElementData) {
|
updateElement(data: UpdateElementData) {
|
||||||
const { id, props } = data
|
const { id, props, slideId } = data
|
||||||
const elIdList = typeof id === 'string' ? [id] : id
|
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 slide = this.slides[slideIndex]
|
||||||
const elements = slide.elements.map(el => {
|
const elements = slide.elements.map(el => {
|
||||||
return elIdList.includes(el.id) ? { ...el, ...props } : el
|
return elIdList.includes(el.id) ? { ...el, ...props } : el
|
||||||
|
@ -157,6 +157,22 @@ const link: MarkSpec = {
|
|||||||
toDOM: node => ['a', node.attrs, 0],
|
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
|
const { em, strong, code } = marks
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -172,4 +188,5 @@ export default {
|
|||||||
strikethrough,
|
strikethrough,
|
||||||
underline,
|
underline,
|
||||||
link,
|
link,
|
||||||
|
mark,
|
||||||
}
|
}
|
@ -7,6 +7,10 @@
|
|||||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="重做">
|
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="重做">
|
||||||
<IconNext class="handler-item" :class="{ 'disable': !canRedo }" @click="redo()" />
|
<IconNext class="handler-item" :class="{ 'disable': !canRedo }" @click="redo()" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Divider type="vertical" style="height: 20px;" />
|
||||||
|
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="查找/替换" @click="openSraechPanel()">
|
||||||
|
<IconSearch class="handler-item" />
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="add-element-handler">
|
<div class="add-element-handler">
|
||||||
@ -138,6 +142,7 @@ import FileInput from '@/components/FileInput.vue'
|
|||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Popover,
|
Popover,
|
||||||
|
Divider,
|
||||||
Modal,
|
Modal,
|
||||||
} from 'ant-design-vue'
|
} from 'ant-design-vue'
|
||||||
|
|
||||||
@ -215,6 +220,11 @@ const drawLine = (line: LinePoolItem) => {
|
|||||||
})
|
})
|
||||||
linePoolVisible.value = false
|
linePoolVisible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开选择替换面板
|
||||||
|
const openSraechPanel = () => {
|
||||||
|
mainStore.setSearchPanelState(true)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
146
src/views/Editor/SearchPanel.vue
Normal file
146
src/views/Editor/SearchPanel.vue
Normal 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>
|
@ -17,6 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectPanel v-if="showSelectPanel" />
|
<SelectPanel v-if="showSelectPanel" />
|
||||||
|
<SearchPanel v-if="showSearchPanel" />
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
:open="!!dialogForExport"
|
:open="!!dialogForExport"
|
||||||
@ -46,10 +47,11 @@ import Toolbar from './Toolbar/index.vue'
|
|||||||
import Remark from './Remark/index.vue'
|
import Remark from './Remark/index.vue'
|
||||||
import ExportDialog from './ExportDialog/index.vue'
|
import ExportDialog from './ExportDialog/index.vue'
|
||||||
import SelectPanel from './SelectPanel.vue'
|
import SelectPanel from './SelectPanel.vue'
|
||||||
|
import SearchPanel from './SearchPanel.vue'
|
||||||
import { Modal } from 'ant-design-vue'
|
import { Modal } from 'ant-design-vue'
|
||||||
|
|
||||||
const mainStore = useMainStore()
|
const mainStore = useMainStore()
|
||||||
const { dialogForExport, showSelectPanel } = storeToRefs(mainStore)
|
const { dialogForExport, showSelectPanel, showSearchPanel } = storeToRefs(mainStore)
|
||||||
const closeExportDialog = () => mainStore.setDialogForExport('')
|
const closeExportDialog = () => mainStore.setDialogForExport('')
|
||||||
|
|
||||||
const remarkHeight = ref(40)
|
const remarkHeight = ref(40)
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
@mousedown="$event => handleSelectElement($event)"
|
@mousedown="$event => handleSelectElement($event)"
|
||||||
@touchstart="$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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user