这是一个稍微复杂的功能了,因为 element-tiptap 中没有查找替换功能,需要从零开始开发。但是,在万能的github上有一个开源的库,我们可以借用一下 tiptap-search-and-replace
不过这个库是没有UI的,只有一个扩展的方法。但是这个关键的方法只要有了,剩下的就简单多了
searchAndReplace.ts
我的项目的目录名文件名都已经从首字母大写改成了全部小写的写法,不影响大家阅读哦
我们首先把这个源码放到 scr/extensions 目录下面
然后UI我们可以参考在线WPS的UI
先分析一下需求。我发现开发之前的需求分析真的很重要,可能这就是所谓的“慢即是快”,看似有些繁琐并且浪费时间,但是可以在开发的时候少走很多弯路,可以让自己开发的思路更加的清晰,效率也会更高。
- 新建一个扩展
- 需要一个下拉框组件,点击按钮出现查找和替换两个菜单项;点击查找、替换菜单项的时候,都需要弹出弹出框组件,并且把状态传递给弹出框组件
- 需要一个弹出框组件,有两种状态,表示当前是查找还是替换;弹出框组件分为两个tab栏;查找可以找上一个、下一个;替换可以找上一个、下一个、全部替换、替换当前
- 查找可以用快捷键⌘F唤醒,替换可以用快捷键⌘H唤醒
更多选项里面的功能就先不做了
1、新建一个扩展
① 新建一个扩展,src/extensions/search-replace.ts,然后把上面的文件的源码放进去,当然,我们后续还需要对它进行稍微的改造,这里先不管
② 在 src/extensions/index.ts 文件中模仿其他扩展,也导出这个扩展
export { default as SearchAndReplace } from './search-replace';
③ 在 src/components/xkw-editor.vue 文件中的扩展列表 extensions
增加一项,根据官网的提示,需要增加 configure
配置项
import {
SearchAndReplace
} from '../extensions';
SearchAndReplace.configure({
searchResultClass: "search-result", // class to give to found items. default 'search-result'
caseSensitive: false, // no need to explain
disableRegex: false, // also no need to explain
}),
2、下拉框组件
① 创建下拉框组件 src/components/menu-commands/search-replace/search-replace.dropdown.vue
ps:我这里的文件命名自己有根据项目需要修改过,大家自行修改哈
代码说明:
- 下拉框菜单有两个:查找、替换
- 点击查找或者替换的时候,会将对应的标识字符串传递到回调函数中,以此标识当前的操作类型
- 一个弹出框组件,当点击查找或替换的时候都会被激活,并且接受表示操作类型的参数
<template>
<el-dropdown placement="bottom" trigger="click" @command="handleCommand" popper-class="my-dropdown"
:popper-options="{ modifiers: [{ name: 'computeStyles', options: { adaptive: false } }] }">
<div>
<command-button :enable-tooltip="enableTooltip" :tooltip="t('editor.extensions.searchAndReplace.tooltip')"
icon="search" :button-icon="buttonIcon" />
</div>
<template #dropdown>
<el-dropdown-menu class="el-tiptap-dropdown-menu">
<el-dropdown-item command="search">
<span>查找</span>
</el-dropdown-item>
<el-dropdown-item command="replace">
<span>替换</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<search-replace-popup v-if="showPopup" :mode="popupMode" :editor="editor" @close="showPopup = false" />
</template>
<script lang="ts">
import { defineComponent, inject, ref } from 'vue';
import { Editor } from '@tiptap/vue-3';
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus';
import CommandButton from '../command.button.vue';
import searchReplacePopup from './search-replace.popup.vue';
export default defineComponent({
name: 'searchAndReplaceDropdown',
components: {
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
CommandButton,
searchReplacePopup,
},
props: {
editor: {
type: Object as () => Editor,
required: true,
},
buttonIcon: {
default: '',
type: String
}
},
setup(props) {
const t = inject('t') as (key: string) => string;
const enableTooltip = inject('enableTooltip', true);
const showPopup = ref(false);
const popupMode = ref<'search' | 'replace'>('search');
const handleCommand = (command: string) => {
popupMode.value = command as 'search' | 'replace';
showPopup.value = true;
};
return {
t,
enableTooltip,
showPopup,
popupMode,
handleCommand
};
},
});
</script>
<style scoped>
.dropdown-title {
font-size: 14px;
font-weight: 500;
margin: 5px;
}
.el-tiptap-dropdown-menu__item {
margin-left: 5px;
}
</style>
② 这里的图标 search 需要我们自己添加
src/icons/search.svg
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke-width="1.5"><g id="group-0" stroke="#333333" fill="#333333"><path d="M11.2426 11.2426L14.5 14.5M13 7C13 10.3137 10.3137 13 7 13C3.68629 13 1 10.3137 1 7C1 3.68629 3.68629 1 7 1C10.3137 1 13 3.68629 13 7Z" stroke-linecap="round" stroke-linejoin="miter" fill="none" vector-effect="non-scaling-stroke"></path></g></svg>
③ 扩展文件修改,应用下拉框组件
主要是 addOptions
方法
import searchAndReplaceDropdown from "@/components/menu-commands/search-replace/search-replace.dropdown.vue";
addOptions() {
return {
// 保留父扩展的所有选项
...this.parent?.(),
button({ editor, t }: { editor: Editor; t: (...args: any[]) => string }) {
return {
component: searchAndReplaceDropdown,
componentProps: {
editor,
},
};
},
};
},
3、弹出框组件
src/components/menu-commands/search-replace/search-replace.popup.vue
① 创建弹出框组件
组件说明:
- UI 参考WPS编辑器的效果
- 查找、替换、上一个、下一个、替换全部 这些功能已经被我们的扩展文件添加到了
editor.commands
上,所以直接通过命令调用即可 - 输入框中文字改变的时候,就需要执行查找
- 点击右上角的叉号关闭弹出框的时候,要去除所有的选中状态
<template>
<div v-if="drawerVisible" class="search-replace-container" :style="containerStyle">
<span class="search-replace-close" type="text" @click="handleClose">
×
</span>
<el-tabs v-model="activeTab">
<el-tab-pane label="查找" name="search">
<div class="search-replace-title">
查找
</div>
<el-input v-model="searchTerm"
@input="onSearchTermChange"
size="default"
:placeholder="t('editor.extensions.searchAndReplace.searchPlaceholder')"></el-input>
<div class="search-replace-actions">
<el-button size="default" @click="findPrevious">上一个</el-button>
<el-button size="default" @click="findNext">下一个</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="替换" name="replace">
<div class="search-replace-title">
查找
</div>
<el-input v-model="searchTerm"
@input="onSearchTermChange"
size="default"
:placeholder="t('editor.extensions.searchAndReplace.searchPlaceholder')"></el-input>
<div class="search-replace-title">
替换为
</div>
<el-input v-model="replaceTerm"
size="default"
:placeholder="t('editor.extensions.searchAndReplace.replacePlaceholder')"></el-input>
<div class="search-replace-actions">
<el-button size="default" @click="findPrevious">上一个</el-button>
<el-button size="default" @click="findNext">下一个</el-button>
<el-button size="default" @click="replaceCurrent">替换</el-button>
<el-button size="default" @click="replaceAll">替换全部</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch, inject, computed } from 'vue';
import { Editor } from '@tiptap/vue-3';
import { ElTabs, ElTabPane, ElInput, ElButton } from 'element-plus';
export default defineComponent({
name: 'searchAndReplacePopup',
components: {
ElTabs,
ElTabPane,
ElInput,
ElButton,
},
props: {
mode: {
type: String as () => 'search' | 'replace',
required: true,
},
editor: {
type: Object as () => Editor,
required: true,
},
},
emits: ['close'],
setup(props, { emit }) {
const t = inject('t') as (key: string) => string;
const drawerVisible = ref(true);
const activeTab = ref(props.mode);
const searchTerm = ref('');
const replaceTerm = ref('');
watch(() => props.mode, (newMode) => {
activeTab.value = newMode;
});
const handleClose = () => {
emit('close');
searchTerm.value = '';
replaceTerm.value = '';
props.editor.commands.setSearchTerm('');
props.editor.commands.resetIndex();
};
const findNext = () => {
props.editor.commands.setSearchTerm(searchTerm.value);
props.editor.commands.nextSearchResult();
};
const findPrevious = () => {
props.editor.commands.setSearchTerm(searchTerm.value);
props.editor.commands.previousSearchResult();
};
const replaceCurrent = () => {
props.editor.commands.setReplaceTerm(replaceTerm.value);
props.editor.commands.replace();
};
const replaceAll = () => {
props.editor.commands.setReplaceTerm(replaceTerm.value);
props.editor.commands.replaceAll();
};
const onSearchTermChange = () => {
props.editor.commands.setSearchTerm(searchTerm.value);
};
// 动态计算容器宽度
const containerStyle = computed(() => ({
width: activeTab.value === 'search' ? '358px' : '478px',
}));
return {
t,
drawerVisible,
activeTab,
searchTerm,
replaceTerm,
handleClose,
findNext,
findPrevious,
replaceCurrent,
replaceAll,
onSearchTermChange,
containerStyle,
};
},
});
</script>
<style lang="scss">
@import '../../../styles/variables.scss';
.search-replace-container {
position: absolute;
top: 10%;
right: 10%;
width: 30%;
background-color: white;
border: 1px solid #ccc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 16px;
z-index: 1000;
border-radius: 5px;
transition: width 0.3s ease; /* 添加过渡效果 */
}
.search-replace-container .el-tabs__item {
font-size: 16px;
}
.search-replace-container .el-tabs__nav-wrap::after {
background-color: transparent;
}
.search-replace-close {
cursor: pointer;
font-size: 18px;
color: #60646c;
font-weight: 600;
position: absolute;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
top: 22px;
right: 18px;
z-index: 3;
}
.search-replace-close:hover {
background-color: #f0f0f0;
}
.search-replace-title {
font-weight: 600;
font-size: 14px;
line-height: 22px;
color: hsla(0, 0%, 5%, .9);
margin-top: 8px;
margin-bottom: 3px;
}
.search-result {
background: $lighter-primary-color;
}
.search-result.search-result-current {
background: $tiptap-search-result-current-color;
}
.search-replace-actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
② 背景颜色定义
如上图所示,查找的时候会查找到很多结果,所有的结果都会被添加类名 search-result
,不过当前选中的结果还会被添加 search-result-current
类名。
上面的代码中,我们给这两个类添加了背景颜色,背景颜色是通过 @import '../../../styles/variables.scss';
引入进来的,在这个文件中,还需要增加一个定义
src/styles/variables.scss
$tiptap-search-result-current-color:rgb(193, 243, 181);
这样就可以实现如下效果
4、替换方法改造
这里有一个小问题,就是替换功能总是会替换第一个查找结果,但是期望结果应该是替换我们选中的结果
此时选中的是第三个 Content
,点击替换的时候,还是会替换第一个 Content
这其实是源码中的 replace
方法的问题,我们看一下这个方法的定义
const replace = (
replaceTerm: string,
results: Range[],
{ state, dispatch }: { state: EditorState; dispatch: Dispatch },
) => {
const firstResult = results[0];
if (!firstResult) return;
const { from, to } = results[0];
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
};
可以看到确实是进行的是结果列表中第一个元素的替换
这个方法的调用在 addCommands
里:
replace:
() =>
({ editor, state, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
console.log(editor.storage.searchAndReplace);
console.log(results);
replace(replaceTerm, results, { state, dispatch });
return false;
},
控制台输出一下 editor.storage.searchAndReplace
,我们可以发现,有一个属性可以标识当前选中的是哪一个结果
索引为 2,也就是第三个。
那么我们可以改造一下 replace
函数,接收一个索引的参数,来指定替换哪一个
// 替换当前搜索结果
const replace = (
replaceTerm: string,
results: Range[],
resultIndex: number,
{ state, dispatch }: { state: EditorState; dispatch: Dispatch },
) => {
if (resultIndex < 0 || resultIndex >= results.length) return;
const { from, to } = results[resultIndex];
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
};
这样就可以实现 替换当前选中的结果 了
5、快捷键
① src/extensions/search-replace.ts 增加快捷键
如果当前鼠标选中的区域有文本的话,就需要获取到选中区域的文本,传递给 setSearchTerm
命令
addKeyboardShortcuts() {
return {
'Mod-f': () => {
const { state } = this.editor;
const { from, to } = state.selection;
const selectedText = state.doc.textBetween(from, to);
this.editor.commands.setSearchTerm(selectedText);
this.editor.emit('openSearchReplacePopup', 'search');
return true;
},
'Mod-h': () => {
const { state } = this.editor;
const { from, to } = state.selection;
const selectedText = state.doc.textBetween(from, to);
this.editor.commands.setSearchTerm(selectedText);
this.editor.commands.setReplaceTerm('');
this.editor.emit('openSearchReplacePopup', 'replace');
return true;
},
};
},
② src/components/menu-commands/search-replace/search-replace.dropdown.vue
我们知道,弹出框显示与否,是通过这个组件里面的 showPopup
属性控制的,快捷键按下的时候,会使用 emit
触发 openSearchReplacePopup
事件。那么在下拉框组件中,我们需要监听openSearchReplacePopup
事件,在回调函数中将showPopup
属性设置为 true
onMounted(() => {
props.editor.on('openSearchReplacePopup', (mode: 'search' | 'replace') => {
popupMode.value = mode;
showPopup.value = true;
});
});
③ src/components/menu-commands/search-replace/search-replace.popup.vue
在这个组件中,searchTerm
我们之前是初始化为''
的,但是现在这个值,需要从编辑器的属性中获取
const searchTerm = ref(props.editor.storage.searchAndReplace.searchTerm);
另外,还有一个小细节,如图,我们选中了第三个 Content
,并且按下了快捷键
此时会发生什么?会发现当前的查找结果是第一个 Content
这不是我想要的。
应该是,咱们鼠标选中的内容作为当前查找结果。
那么此时我们就需要在查找结果数组中,找到和我们鼠标选中区域的位置一样的查找结果,并且选中这个查找结果
const selection = props.editor.state.selection;
const results = props.editor.storage.searchAndReplace.results;
if(results.length > 0) {
// 找到 results 中,from 和 selection.from 一样的结果,然后设置为当前结果
for(let i = 0; i < results.length; i++) {
if(results[i].from !== selection.from) {
// 向后找
props.editor.commands.nextSearchResult();
}else{
break;
}
}
}
然后你会发现,现在选中第三个 Content
按下快捷键是这种效果
其实不是bug了,是因为当前我们用鼠标选中了第三个 Content
,并且鼠标选中的背景色跟我们设置的选中结果的背景色一样😂😂😂,就这个我以为是bug看了好久好久。。。。。。
不如改个颜色直观一些吧
src/styles/variables.scss
$tiptap-search-result-color:#FDFF00;
$tiptap-search-result-current-color:#F79632;
src/components/menu-commands/search-replace/search-replace.popup.vue
.search-result {
display: inline-block;
background: $tiptap-search-result-color;
}
.search-result.search-result-current {
background: $tiptap-search-result-current-color;
}
随便点击一下页面
长呼一口气,这个还真是有点复杂。