Feat:支持搜索和替换

This commit is contained in:
wanglin2 2023-07-28 08:50:29 +08:00
parent 5a5c7702f5
commit 07be48d342
6 changed files with 353 additions and 8 deletions

View File

@ -10,6 +10,7 @@ import AssociativeLine from './src/plugins/AssociativeLine'
import RichText from './src/plugins/RichText'
import NodeImgAdjust from './src/plugins/NodeImgAdjust.js'
import TouchEvent from './src/plugins/TouchEvent.js'
import Search from './src/plugins/Search.js'
import xmind from './src/parse/xmind.js'
import markdown from './src/parse/markdown.js'
import icons from './src/svg/icons.js'
@ -36,5 +37,6 @@ MindMap
.usePlugin(RichText)
.usePlugin(TouchEvent)
.usePlugin(NodeImgAdjust)
.usePlugin(Search)
export default MindMap

View File

@ -975,7 +975,8 @@ class Render {
setNodeText(node, text, richText) {
this.setNodeDataRender(node, {
text,
richText
richText,
resetRichText: richText
})
}
@ -1101,7 +1102,7 @@ class Render {
}
// 定位到指定节点
goTargetNode(node) {
goTargetNode(node, callback = () => {}) {
let uid = typeof node === 'string' ? node : node.nodeData.data.uid
if (!uid) return
this.expandToNodeUid(uid, () => {
@ -1109,6 +1110,7 @@ class Render {
if (targetNode) {
targetNode.active()
this.moveNodeToCenter(targetNode)
callback()
}
})
}
@ -1121,7 +1123,7 @@ class Render {
}
// 设置节点数据,并判断是否渲染
setNodeDataRender(node, data) {
setNodeDataRender(node, data, notRender = false) {
this.setNodeData(node, data)
let changed = node.reRender()
if (changed) {
@ -1129,7 +1131,7 @@ class Render {
// 概要节点
node.generalizationBelongNode.updateGeneralization()
}
this.mindMap.render()
if (!notRender) this.mindMap.render()
}
}

View File

@ -0,0 +1,143 @@
import { bfsWalk, getTextFromHtml } from '../utils/index'
// 搜索插件
class Search {
// 构造函数
constructor({ mindMap }) {
this.mindMap = mindMap
// 是否正在搜索
this.isSearching = false
// 搜索文本
this.searchText = ''
// 匹配的节点列表
this.matchNodeList = []
// 当前所在的节点列表索引
this.currentIndex = -1
// 是否正在跳转中
this.isJumping = false
this.onDataChange = this.onDataChange.bind(this)
this.mindMap.on('data_change', this.onDataChange)
}
// 节点数据改变了,需要重新搜索
onDataChange() {
if (this.isJumping) return
this.searchText = ''
}
// 搜索
search(text, callback) {
text = String(text).trim()
if (!text) return this.endSearch()
this.isSearching = true
if (this.searchText === text) {
// 和上一次搜索文本一样,那么搜索下一个
this.searchNext(callback)
} else {
// 和上次搜索文本不一样,那么重新开始
this.searchText = text
this.doSearch()
this.searchNext(callback)
}
this.emitEvent()
}
// 结束搜索
endSearch() {
if (!this.isSearching) return
this.searchText = ''
this.matchNodeList = []
this.currentIndex = -1
this.isJumping = false
this.isSearching = false
this.emitEvent()
}
// 搜索匹配的节点
doSearch() {
this.matchNodeList = []
this.currentIndex = -1
bfsWalk(this.mindMap.renderer.root, node => {
let { richText, text } = node.nodeData.data
if (richText) {
text = getTextFromHtml(text)
}
if (text.includes(this.searchText)) {
this.matchNodeList.push(node)
}
})
}
// 搜索下一个,定位到下一个匹配节点
searchNext(callback) {
if (!this.isSearching || this.matchNodeList.length <= 0) return
if (this.currentIndex < this.matchNodeList.length - 1) {
this.currentIndex++
} else {
this.currentIndex = 0
}
let currentNode = this.matchNodeList[this.currentIndex]
this.isJumping = true
this.mindMap.execCommand('GO_TARGET_NODE', currentNode, () => {
this.isJumping = false
callback()
})
}
// 替换当前节点
replace(replaceText) {
replaceText = String(replaceText).trim()
if (!replaceText || !this.isSearching || this.matchNodeList.length <= 0)
return
let currentNode = this.matchNodeList[this.currentIndex]
if (!currentNode) return
let text = this.getReplacedText(currentNode, this.searchText, replaceText)
currentNode.setText(text, currentNode.nodeData.data.richText)
this.matchNodeList = this.matchNodeList.filter(node => {
return currentNode !== node
})
this.emitEvent()
}
// 替换所有
replaceAll(replaceText) {
replaceText = String(replaceText).trim()
if (!replaceText || !this.isSearching || this.matchNodeList.length <= 0)
return
this.matchNodeList.forEach(node => {
let text = this.getReplacedText(node, this.searchText, replaceText)
this.mindMap.renderer.setNodeDataRender(
node,
{
text,
resetRichText: !!node.nodeData.data.richText
},
true
)
})
this.mindMap.render()
this.mindMap.command.addHistory()
this.endSearch()
}
// 获取某个节点替换后的文本
getReplacedText(node, searchText, replaceText) {
let { richText, text } = node.nodeData.data
if (richText) {
text = getTextFromHtml(text)
}
return text.replaceAll(searchText, replaceText)
}
// 发送事件
emitEvent() {
this.mindMap.emit('search_info_change', {
currentIndex: this.currentIndex,
total: this.matchNodeList.length
})
}
}
Search.instanceName = 'search'
export default Search

View File

@ -18,6 +18,7 @@
></NodeNoteContentShow>
<NodeImgPreview v-if="mindMap" :mindMap="mindMap"></NodeImgPreview>
<SidebarTrigger v-if="!isZenMode"></SidebarTrigger>
<Search v-if="mindMap" :mindMap="mindMap"></Search>
</div>
</template>
@ -35,6 +36,7 @@ import RichText from 'simple-mind-map/src/plugins/RichText.js'
import AssociativeLine from 'simple-mind-map/src/plugins/AssociativeLine.js'
import TouchEvent from 'simple-mind-map/src/plugins/TouchEvent.js'
import NodeImgAdjust from 'simple-mind-map/src/plugins/NodeImgAdjust.js'
import SearchPlugin from 'simple-mind-map/src/plugins/Search.js'
import Outline from './Outline'
import Style from './Style'
import BaseStyle from './BaseStyle'
@ -59,6 +61,7 @@ import Vue from 'vue'
import router from '../../../router'
import store from '../../../store'
import i18n from '../../../i18n'
import Search from './Search.vue'
//
MindMap
@ -73,6 +76,7 @@ MindMap
.usePlugin(AssociativeLine)
.usePlugin(NodeImgAdjust)
.usePlugin(TouchEvent)
.usePlugin(SearchPlugin)
//
// customThemeList.forEach((item) => {
@ -100,7 +104,8 @@ export default {
NodeNoteContentShow,
Navigator,
NodeImgPreview,
SidebarTrigger
SidebarTrigger,
Search
},
data() {
return {

View File

@ -0,0 +1,188 @@
<template>
<div class="searchContainer" :class="{ isDark: isDark, show: show }">
<div class="closeBtnBox">
<span class="closeBtn el-icon-close" @click="close"></span>
</div>
<div class="searchInputBox">
<el-input
ref="input"
placeholder="请输入查找内容"
size="small"
v-model="searchText"
@keyup.native.enter.stop="onSearchNext"
>
<i slot="prefix" class="el-input__icon el-icon-search"></i>
<el-button
size="small"
slot="append"
v-if="!!searchText.trim()"
@click="showReplaceInput = true"
>替换</el-button
>
</el-input>
<div class="searchInfo" v-if="showSearchInfo">
{{ currentIndex }} / {{ total }}
</div>
</div>
<el-input
v-if="showReplaceInput"
placeholder="请输入替换内容"
size="small"
v-model="replaceText"
style="margin: 12px 0;"
>
<i slot="prefix" class="el-input__icon el-icon-edit"></i>
<el-button size="small" slot="append" @click="hideReplaceInput"
>取消</el-button
>
</el-input>
<div class="btnList" v-if="showReplaceInput">
<el-button size="small" @click="replace">替换</el-button>
<el-button size="small" @click="replaceAll">全部替换</el-button>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
//
export default {
name: 'Search',
props: {
mindMap: {
type: Object
}
},
data() {
return {
show: false,
searchText: '',
replaceText: '',
showReplaceInput: false,
currentIndex: 0,
total: 0,
showSearchInfo: false
}
},
computed: {
...mapState(['isDark'])
},
watch: {
searchText() {
if (!this.searchText.trim()) {
this.currentIndex = 0
this.total = 0
this.showSearchInfo = false
}
}
},
created() {
this.mindMap.on('search_info_change', data => {
this.currentIndex = data.currentIndex + 1
this.total = data.total
this.showSearchInfo = true
})
this.mindMap.keyCommand.addShortcut('Control+f', () => {
this.$bus.$emit('closeSideBar')
this.show = true
this.$refs.input.focus()
})
},
methods: {
hideReplaceInput() {
this.showReplaceInput = false
this.replaceText = ''
},
onSearchNext() {
this.mindMap.search.search(this.searchText, () => {
this.$refs.input.focus()
})
},
replace() {
this.mindMap.search.replace(this.replaceText)
},
replaceAll() {
this.mindMap.search.replaceAll(this.replaceText)
},
close() {
this.show = false
this.showSearchInfo = false
this.total = 0
this.currentIndex = 0
this.searchText = ''
this.hideReplaceInput()
this.mindMap.search.endSearch()
}
}
}
</script>
<style lang="less" scoped>
.searchContainer {
position: relative;
background-color: #fff;
padding: 16px;
width: 296px;
border-radius: 12px;
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.1);
position: fixed;
top: 110px;
right: -296px;
transition: all 0.3s;
&.isDark {
background-color: #363b3f;
.closeBtnBox {
color: #fff;
background-color: #363b3f;
}
}
&.show {
right: 20px;
}
.btnList {
display: flex;
justify-content: flex-end;
}
.closeBtnBox {
position: absolute;
right: -5px;
top: -5px;
width: 20px;
height: 20px;
background-color: #fff;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.1);
.closeBtn {
font-size: 16px;
}
}
.searchInputBox {
position: relative;
.searchInfo {
position: absolute;
right: 70px;
top: 50%;
transform: translateY(-50%);
color: #909090;
font-size: 14px;
}
}
}
</style>

View File

@ -39,7 +39,7 @@ export default {
}
},
computed: {
...mapState(['isDark']),
...mapState(['isDark'])
},
watch: {
show(val, oldVal) {
@ -48,6 +48,11 @@ export default {
}
}
},
created() {
this.$bus.$on('closeSideBar', () => {
this.close()
})
},
methods: {
...mapMutations(['setActiveSidebar']),
@ -74,10 +79,10 @@ export default {
&.isDark {
background-color: #262a2e;
border-left-color: hsla(0,0%,100%,.1);
border-left-color: hsla(0, 0%, 100%, 0.1);
.sidebarHeader {
border-bottom-color: hsla(0,0%,100%,.1);
border-bottom-color: hsla(0, 0%, 100%, 0.1);
color: #fff;
}