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',
|
||||
borderRadius: 2,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5715,
|
||||
},
|
||||
}"
|
||||
>
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
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,
|
||||
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 {
|
||||
|
@ -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
|
||||
},
|
||||
},
|
||||
})
|
@ -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
|
||||
|
@ -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,
|
||||
}
|
@ -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>
|
||||
|
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>
|
||||
|
||||
<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)
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user