feat: provide find and replace functionality for the default rich text editor (#5206)

#### What type of PR is this?

/kind feature

#### What this PR does / why we need it:

为默认富文本编辑器添加查找与搜索的功能扩展。

快捷键:

当焦点处于编辑器中时,可以使用 `Mod+f` 来唤起查找与搜索框,或者点击顶部工具栏来打开。
当焦点处于查找与搜索框时,按下 `Ecs` 可进行关闭。

<img width="1920" alt="image" src="https://github.com/halo-dev/halo/assets/31335418/03a54bb8-2cc4-4cb0-9a18-fd0e9aede564">

#### How to test it?

测试查找与搜索功能是否正常

#### Which issue(s) this PR fixes:

Fixes #5195 

#### Does this PR introduce a user-facing change?
```release-note
为默认富文本编辑器添加查找与搜索的功能扩展。
```
pull/5245/head
Takagi 2024-01-24 15:07:21 +08:00 committed by GitHub
parent ddbc73b079
commit 38465253c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1133 additions and 3 deletions

View File

@ -63,7 +63,7 @@ function getToolboxItemsFromExtensions() {
>
<div class="inline-flex items-center justify-center">
<VMenu>
<button class="p-1 rounded-sm hover:bg-gray-100">
<button class="p-1 rounded-sm hover:bg-gray-100" tabindex="-1">
<MdiPlusCircle class="text-[#4CCBA0]" />
</button>
<template #popper>
@ -75,6 +75,7 @@ function getToolboxItemsFromExtensions() {
v-for="(toolboxItem, index) in getToolboxItemsFromExtensions()"
v-bind="toolboxItem.props"
:key="index"
tabindex="-1"
/>
</div>
</template>
@ -90,9 +91,10 @@ function getToolboxItemsFromExtensions() {
:is="item.component"
v-if="!item.children?.length"
v-bind="item.props"
tabindex="-1"
/>
<VMenu v-else class="inline-flex">
<component :is="item.component" v-bind="item.props" />
<VMenu v-else class="inline-flex" tabindex="-1">
<component :is="item.component" v-bind="item.props" tabindex="-1" />
<template #popper>
<div
class="relative rounded-md bg-white overflow-hidden drop-shadow w-48 p-1 max-h-72 overflow-y-auto"
@ -102,6 +104,7 @@ function getToolboxItemsFromExtensions() {
:is="child.component"
v-for="(child, childIndex) in item.children"
:key="childIndex"
tabindex="-1"
/>
</div>
</template>

View File

@ -30,6 +30,7 @@ withDefaults(
]"
class="p-1 rounded-sm"
:disabled="disabled"
tabindex="-1"
@click="action"
>
<component :is="icon" />

View File

@ -44,6 +44,7 @@ import {
ExtensionNodeSelected,
ExtensionTrailingNode,
ExtensionListKeymap,
ExtensionSearchAndReplace,
} from "../index";
const content = useLocalStorage("content", "");
@ -109,6 +110,7 @@ const editor = useEditor({
ExtensionNodeSelected,
ExtensionTrailingNode,
ExtensionListKeymap,
ExtensionSearchAndReplace,
],
onUpdate: () => {
content.value = editor.value?.getHTML() + "";

View File

@ -42,6 +42,7 @@ import ExtensionText from "./text";
import ExtensionDraggable from "./draggable";
import ExtensionNodeSelected from "./node-selected";
import ExtensionTrailingNode from "./trailing-node";
import ExtensionSearchAndReplace from "./search-and-replace";
const allExtensions = [
ExtensionBlockquote,
@ -98,6 +99,7 @@ const allExtensions = [
ExtensionColumn,
ExtensionNodeSelected,
ExtensionTrailingNode,
ExtensionSearchAndReplace,
];
export {
@ -144,4 +146,5 @@ export {
ExtensionNodeSelected,
ExtensionTrailingNode,
ExtensionListKeymap,
ExtensionSearchAndReplace,
};

View File

@ -0,0 +1,358 @@
<script setup lang="ts" name="BubbleMenu">
import { ref, type PropType, watch, computed, nextTick } from "vue";
import type { PluginKey, Editor } from "@/tiptap";
import type { SearchAndReplacePluginState } from "./SearchAndReplacePlugin";
import MdiFormatLetterCase from "~icons/mdi/format-letter-case";
import MdiFormatLetterMatches from "~icons/mdi/format-letter-matches";
import MdiRegex from "~icons/mdi/regex";
import MdiArrowUp from "~icons/mdi/arrow-up";
import MdiArrowDown from "~icons/mdi/arrow-down";
import MdiClose from "~icons/mdi/close";
import LucideReplace from "~icons/lucide/replace";
import LucideReplaceAll from "~icons/lucide/replace-all";
import { i18n } from "@/locales";
const props = defineProps({
editor: {
type: Object as PropType<Editor>,
required: true,
},
pluginKey: {
type: Object as PropType<PluginKey<SearchAndReplacePluginState>>,
required: true,
},
visible: {
type: Boolean,
default: false,
},
});
const searchTerm = ref<string>("");
const replaceTerm = ref<string>("");
const regex = ref<boolean>(false);
const caseSensitive = ref<boolean>(false);
const matchWord = ref<boolean>(false);
const flag = ref<boolean>(false);
const findState = computed(() => {
void flag.value;
const { editor, pluginKey } = props;
if (!editor || !pluginKey) {
return {
findIndex: 0,
findCount: 0,
};
}
const state = pluginKey.getState(editor.state);
return {
findIndex: state?.findIndex || 0,
findCount: state?.findCount || 0,
};
});
const findNextSearchResult = () => {
props.editor.commands.findNext();
};
const findPreviousSearchResult = () => {
props.editor.commands.findPrevious();
};
const updateSearchReplace = (value: any) => {
const { editor, pluginKey } = props;
if (!editor || !pluginKey) {
return;
}
const tr = editor.state.tr;
tr.setMeta(pluginKey, value);
editor.view.dispatch(tr);
flag.value = !flag.value;
};
const replace = () => {
props.editor.commands.replace();
flag.value = !flag.value;
};
const replaceAll = () => {
props.editor.commands.replaceAll();
flag.value = !flag.value;
};
const handleCloseSearch = () => {
props.editor.commands.closeSearch();
};
watch(
() => searchTerm.value.trim(),
(val, oldVal) => {
if (val !== oldVal) {
updateSearchReplace({
setSearchTerm: val,
});
}
}
);
watch(
() => replaceTerm.value.trim(),
(val, oldVal) => {
if (val !== oldVal) {
updateSearchReplace({
setReplaceTerm: val,
});
}
}
);
watch(
() => regex.value,
(val, oldVal) => {
if (val !== oldVal) {
updateSearchReplace({
setRegex: val,
});
}
}
);
watch(
() => caseSensitive.value,
(val, oldVal) => {
if (val !== oldVal) {
updateSearchReplace({
setCaseSensitive: val,
});
}
}
);
watch(
() => matchWord.value,
(val, oldVal) => {
if (val !== oldVal) {
updateSearchReplace({
setMatchWord: val,
});
}
}
);
const searchInput = ref<HTMLInputElement | null>(null);
watch(
() => props.visible,
(val) => {
if (val) {
nextTick(() => {
searchInput.value?.focus();
});
}
}
);
</script>
<template>
<Transition v-show="visible" appear name="slide">
<div
class="absolute float-right top-0 right-5 z-50 flex justify-end bg-white shadow p-1 !pt-2 rounded min-w-[500px]"
@keydown.escape.prevent="handleCloseSearch"
>
<section class="w-full flex flex-col gap-1">
<div class="flex items-center relative">
<div class="relative w-full max-w-[55%]">
<input
ref="searchInput"
v-model="searchTerm"
type="text"
class="block w-full p-1 ps-2 !pr-[5.5rem] bg-gray-50 rounded border !border-solid !text-sm !leading-7 border-gray-300 text-gray-900 focus:ring-blue-500 focus:border-blue-500"
:placeholder="
i18n.global.t(
'editor.extensions.search_and_replace.search_placeholder'
)
"
tabindex="2"
@keydown.enter.prevent="findNextSearchResult"
/>
<div class="absolute inset-y-0 end-0 flex items-center pr-1 gap-1">
<button
:title="
i18n.global.t(
'editor.extensions.search_and_replace.case_sensitive'
)
"
type="button"
class="p-0.5 rounded-sm hover:bg-gray-200"
:class="{
'!bg-blue-200 outline outline-1 outline-blue-500 hover:!bg-blue-200':
caseSensitive,
}"
@click="caseSensitive = !caseSensitive"
>
<MdiFormatLetterCase></MdiFormatLetterCase>
</button>
<button
:title="
i18n.global.t(
'editor.extensions.search_and_replace.match_word'
)
"
type="button"
class="p-0.5 rounded-sm hover:bg-gray-200"
:class="{
'!bg-blue-200 outline outline-1 outline-blue-500 hover:!bg-blue-200':
matchWord,
}"
@click="matchWord = !matchWord"
>
<MdiFormatLetterMatches></MdiFormatLetterMatches>
</button>
<button
:title="
i18n.global.t(
'editor.extensions.search_and_replace.use_regex'
)
"
type="button"
class="p-0.5 rounded-sm hover:bg-gray-200"
:class="{
'!bg-blue-200 outline outline-1 outline-blue-500 hover:!bg-blue-200':
regex,
}"
@click="regex = !regex"
>
<MdiRegex></MdiRegex>
</button>
</div>
</div>
<div class="min-w-[130px] text-sm mx-2">
<div v-if="findState.findCount === 0">
<span :class="{ 'text-red-600': searchTerm.length > 0 }">{{
i18n.global.t("editor.extensions.search_and_replace.not_found")
}}</span>
</div>
<div v-else>
<span>
{{
i18n.global.t(
"editor.extensions.search_and_replace.occurrence_found",
{
index: findState.findIndex + 1,
total: findState.findCount,
}
)
}}
</span>
</div>
</div>
<div class="h-full flex items-center absolute right-0">
<button
:title="
i18n.global.t(
'editor.extensions.search_and_replace.find_previous'
)
"
type="button"
class="p-0.5 rounded-sm opacity-50"
:class="{
'hover:!bg-gray-200 !opacity-100': findState.findCount > 0,
}"
:disabled="findState.findCount === 0"
@click="findPreviousSearchResult"
>
<MdiArrowUp></MdiArrowUp>
</button>
<button
:title="
i18n.global.t('editor.extensions.search_and_replace.find_next')
"
type="button"
class="p-0.5 rounded-sm opacity-50"
:class="{
'hover:!bg-gray-200 !opacity-100': findState.findCount > 0,
}"
:disabled="findState.findCount === 0"
@click="findNextSearchResult"
>
<MdiArrowDown></MdiArrowDown>
</button>
<button
:title="
i18n.global.t('editor.extensions.search_and_replace.close')
"
type="button"
class="p-0.5 rounded-sm hover:bg-gray-200"
@click="handleCloseSearch"
>
<MdiClose></MdiClose>
</button>
</div>
</div>
<div class="flex items-center">
<div class="relative w-full max-w-[55%]">
<input
v-model="replaceTerm"
type="text"
class="block w-full p-1 ps-2 rounded bg-gray-50 border !border-solid !text-sm !leading-7 border-gray-300 text-gray-900 focus:ring-blue-500 focus:border-blue-500"
:placeholder="
i18n.global.t(
'editor.extensions.search_and_replace.replace_placeholder'
)
"
tabindex="2"
@keydown.enter.prevent="replace"
/>
</div>
<div class="flex items-center gap-2 mx-2">
<button
:title="
i18n.global.t('editor.extensions.search_and_replace.replace')
"
type="button"
class="p-0.5 rounded-sm opacity-50"
:class="{
'hover:!bg-gray-200 !opacity-100': findState.findCount > 0,
}"
:disabled="findState.findCount === 0"
@click="replace"
>
<LucideReplace></LucideReplace>
</button>
<button
:title="
i18n.global.t(
'editor.extensions.search_and_replace.replace_all'
)
"
type="button"
class="p-0.5 rounded-sm opacity-50"
:class="{
'hover:!bg-gray-200 !opacity-100': findState.findCount > 0,
}"
:disabled="findState.findCount === 0"
@click="replaceAll"
>
<LucideReplaceAll></LucideReplaceAll>
</button>
</div>
</div>
</section>
</div>
</Transition>
</template>
<style>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.25s;
}
.slide-enter-from,
.slide-leave-to {
transform: translateY(-100%);
}
.slide-enter-to,
.slide-leave-from {
transform: translateY(0);
}
</style>

View File

@ -0,0 +1,403 @@
import type { PMNode, Selection } from "@/tiptap";
import {
Decoration,
DecorationSet,
EditorView,
Plugin,
PluginKey,
Transaction,
} from "@/tiptap/pm";
import { Editor } from "@/tiptap/vue-3";
import scrollIntoView from "scroll-into-view-if-needed";
export interface SearchAndReplacePluginProps {
editor: Editor;
element: HTMLElement;
searchResultClass?: string;
findSearchClass?: string;
}
export const searchAndReplacePluginKey =
new PluginKey<SearchAndReplacePluginState>("searchAndReplace");
export type SearchAndReplacePluginViewProps = SearchAndReplacePluginProps & {
view: EditorView;
};
export class SearchAndReplacePluginView {
public editor: Editor;
public view: EditorView;
public containerElement: HTMLElement;
constructor({ view, editor, element }: SearchAndReplacePluginViewProps) {
this.editor = editor;
this.view = view;
this.containerElement = element;
const { element: editorElement } = this.editor.options;
editorElement.insertAdjacentElement("afterbegin", this.containerElement);
}
update() {
return false;
}
destroy() {
return false;
}
}
export interface TextNodesWithPosition {
text: string;
pos: number;
index: number;
}
export interface SearchResultWithPosition {
pos: number;
index: number;
from: number;
to: number;
}
export class SearchAndReplacePluginState {
private _findIndex: number;
public editor: Editor;
public enable: boolean;
// Whether it is necessary to reset the findIndex based on the cursor position.
public findIndexFlag: boolean;
public findCount: number;
public searchTerm: string;
public replaceTerm: string;
public regex: boolean;
public caseSensitive: boolean;
public wholeWord: boolean;
public results: SearchResultWithPosition[] = [];
public searchResultDecorations: Decoration[] = [];
public findIndexDecoration: Decoration | undefined;
constructor({
editor,
enable,
regex,
caseSensitive,
wholeWord,
}: {
editor: Editor;
enable?: boolean;
regex?: boolean;
caseSensitive?: boolean;
wholeWord?: boolean;
}) {
this.editor = editor;
this.enable = enable || false;
this.searchTerm = "";
this.replaceTerm = "";
this.regex = regex || false;
this.caseSensitive = caseSensitive || false;
this.wholeWord = wholeWord || false;
this._findIndex = 0;
this.findCount = 0;
this.searchResultDecorations = [];
this.findIndexDecoration = undefined;
this.results = [];
this.findIndexFlag = true;
}
get findIndex() {
return this._findIndex;
}
set findIndex(newValue) {
this._findIndex = this.verifySetIndex(newValue);
}
apply(tr: Transaction): SearchAndReplacePluginState {
const action = tr.getMeta(searchAndReplacePluginKey);
if (action && "setEnable" in action) {
if (action.setEnable && !this.enable) {
action.setSearchTerm = this.searchTerm;
}
this.enable = action.setEnable;
}
if (!this.enable) {
return this;
}
// The refresh method needs to be called before setFindIndex
// Because setFindIndex depends on the refreshed results
if (action && action.refresh) {
this.processSearches(tr);
}
if (action && "setReplaceTerm" in action) {
this.replaceTerm = action.setReplaceTerm;
}
if (action && "setFindIndex" in action) {
const { setFindIndex } = action;
this.findIndex = setFindIndex;
this.processFindIndexDecoration();
}
if (action && "setScrollView") {
this.scrollIntoFindIndexView();
}
if (action && "setRegex" in action) {
if (this.regex !== action.setRegex) {
this.regex = action.setRegex;
action.setSearchTerm = this.searchTerm;
}
}
if (action && "setWholeWord" in action) {
if (this.wholeWord !== action.setWholeWord) {
this.wholeWord = action.setWholeWord;
action.setSearchTerm = this.searchTerm;
}
}
if (action && "setCaseSensitive" in action) {
if (this.caseSensitive !== action.setCaseSensitive) {
this.caseSensitive = action.setCaseSensitive;
action.setSearchTerm = this.searchTerm;
}
}
if (action && "setSearchTerm" in action) {
this.searchTerm = action.setSearchTerm;
this.findIndexFlag = true;
// If the searchTerm is modified or replaced, perform a new
// search throughout the entire document.
this.processSearches(tr);
this.scrollIntoFindIndexView();
return this;
}
if (tr.docChanged) {
return this.processSearches(tr);
} else if (tr.getMeta("pointer")) {
this.getNearestResultBySelection(tr.selection);
this.processFindIndexDecoration();
}
return this;
}
scrollIntoFindIndexView() {
const { results, editor, _findIndex } = this;
if (results.length > _findIndex && _findIndex >= 0) {
const result = results[_findIndex];
if (result) {
const { pos } = result;
const { view } = editor;
let node = view.nodeDOM(pos - 1);
if (!(node instanceof HTMLElement)) {
node = view.domAtPos(pos, 0).node;
}
if (node instanceof HTMLElement) {
scrollIntoView(node, {
behavior: "smooth",
scrollMode: "if-needed",
});
}
}
}
}
/**
* Validate if findIndex is within the range
* If results.length === 0, take 0
* If less than or equal to -1, take results.length - 1
* If greater than results.length - 1, take 0
*
* @param index new findIndex
* @returns validated findIndex
*/
verifySetIndex(index: number) {
const { results } = this;
if (results.length === 0) {
return 0;
} else if (index <= -1) {
return results.length - 1;
} else if (index > results.length - 1) {
return 0;
} else {
return index;
}
}
/**
* Execute full-text search functionality.
*
* @param Transaction
* @returns
* @memberof SearchAndReplacePluginState
*/
processSearches({
doc,
selection,
}: Transaction): SearchAndReplacePluginState {
const textNodesWithPosition = this.getFullText(doc);
const searchTerm = this.getRegex();
this.results.length = 0;
for (let i = 0; i < textNodesWithPosition.length; i += 1) {
const { text, pos, index } = textNodesWithPosition[i];
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim()
);
for (let j = 0; j < matches.length; j += 1) {
const m = matches[j];
if (m[0] === "") {
break;
}
if (m.index !== undefined) {
this.results.push({
pos: pos,
index: index,
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
this.processResultDecorations();
if (this.findIndexFlag) {
this.getNearestResultBySelection(selection);
this.findIndexFlag = false;
}
this.processFindIndexDecoration();
return this;
}
/**
* Highlight the current result based on findIndex.
*
* @memberof SearchAndReplacePluginState
*/
processFindIndexDecoration() {
const { results, findIndex } = this;
const result = results[findIndex];
if (result) {
this.findIndexDecoration = Decoration.inline(result.from, result.to, {
class: "search-result-current",
});
}
}
/**
* Generate highlighted results based on the 'results'.
*
* @memberof SearchAndReplacePluginState
*/
processResultDecorations() {
const { results } = this;
this.findCount = results.length;
this.searchResultDecorations.length = 0;
for (let i = 0; i < results.length; i += 1) {
const result = results[i];
this.searchResultDecorations.push(
Decoration.inline(result.from, result.to, {
class: "search-result",
})
);
}
}
/**
* Reset findIndex based on the current cursor position.
*
* @param selection Current cursor position.
*/
getNearestResultBySelection(selection: Selection) {
const { results } = this;
for (let i = 0; i < results.length; i += 1) {
const result = results[i];
if (selection && selection.to <= result.from) {
this.findIndex = i;
break;
}
}
}
/**
* Convert the entire text into flattened text with positions.
*
* @param doc The entire document
* @returns Flattened text with positions
*/
getFullText(doc: PMNode): TextNodesWithPosition[] {
const textNodesWithPosition: TextNodesWithPosition[] = [];
doc.descendants((node, pos, parent, index) => {
if (node.isText) {
textNodesWithPosition.push({
text: `${node.text}`,
pos,
index,
});
}
});
return textNodesWithPosition;
}
/**
* Get the regular expression object based on the current search term.
*
* @returns Regular expression object
*/
getRegex = (): RegExp => {
const { searchTerm, regex, caseSensitive, wholeWord } = this;
let pattern = regex
? searchTerm
: searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (wholeWord) {
pattern = `\\b${pattern}\\b`;
}
return new RegExp(pattern, caseSensitive ? "gu" : "gui");
};
}
export const SearchAndReplacePlugin = (
options: SearchAndReplacePluginProps
) => {
return new Plugin({
key: searchAndReplacePluginKey,
view: (view) => new SearchAndReplacePluginView({ view, ...options }),
state: {
init: () => {
return new SearchAndReplacePluginState({ ...options });
},
apply: (tr, prev) => {
return prev.apply(tr);
},
},
props: {
decorations: (state) => {
const searchAndReplaceState = searchAndReplacePluginKey.getState(state);
if (searchAndReplaceState) {
const { searchResultDecorations, findIndexDecoration, enable } =
searchAndReplaceState;
if (!enable) {
return DecorationSet.empty;
}
const decorations = [...searchResultDecorations];
if (findIndexDecoration) {
decorations.push(findIndexDecoration);
}
if (decorations.length > 0) {
return DecorationSet.create(state.doc, decorations);
}
}
return DecorationSet.empty;
},
},
});
};

View File

@ -0,0 +1,295 @@
import { Editor, Extension } from "@/tiptap/vue-3";
import {
SearchAndReplacePlugin,
searchAndReplacePluginKey,
} from "./SearchAndReplacePlugin";
import SearchAndReplaceVue from "./SearchAndReplace.vue";
import { h, markRaw, render } from "vue";
import { EditorState } from "@/tiptap/pm";
import type { ExtensionOptions } from "@/types";
import { i18n } from "@/locales";
import { ToolbarItem } from "@/components";
import MdiTextBoxSearchOutline from "~icons/mdi/text-box-search-outline";
declare module "@/tiptap" {
interface Commands<ReturnType> {
searchAndReplace: {
/**
* @description Replace first instance of search result with given replace term.
*/
replace: () => ReturnType;
/**
* @description Replace all instances of search result with given replace term.
*/
replaceAll: () => ReturnType;
/**
* @description Find next instance of search result.
*/
findNext: () => ReturnType;
/**
* @description Find previous instance of search result.
*/
findPrevious: () => ReturnType;
/**
* @description Open search panel.
*/
openSearch: () => ReturnType;
/**
* @description Close search panel.
*/
closeSearch: () => ReturnType;
};
}
}
const instance = h<any>(SearchAndReplaceVue);
function isShowSearch() {
const searchAndReplaceInstance = instance.component;
if (searchAndReplaceInstance) {
return searchAndReplaceInstance.props.visible;
}
return false;
}
const SearchAndReplace = Extension.create<ExtensionOptions>({
name: "searchAndReplace",
// @ts-ignore
addOptions() {
return {
getToolbarItems({ editor }: { editor: Editor }) {
return [
{
priority: 210,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: isShowSearch(),
icon: markRaw(MdiTextBoxSearchOutline),
title: i18n.global.t(
"editor.extensions.search_and_replace.title"
),
action: () => {
const searchAndReplaceInstance = instance.component;
if (searchAndReplaceInstance) {
const visible = searchAndReplaceInstance.props.visible;
if (visible) {
editor.commands.closeSearch();
} else {
editor.commands.openSearch();
}
}
},
},
},
];
},
};
},
addCommands() {
return {
replace:
() =>
({
state,
dispatch,
}: {
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
const searchAndReplaceState =
searchAndReplacePluginKey.getState(state);
if (!searchAndReplaceState) {
return false;
}
const { replaceTerm, results, findIndex } = searchAndReplaceState;
const result = results[findIndex];
if (!result) {
return false;
}
const { from, to } = result;
if (dispatch) {
const tr = state.tr;
tr.insertText(replaceTerm, from, to);
tr.setMeta(searchAndReplacePluginKey, {
setFindIndex: findIndex,
refresh: true,
});
dispatch(tr);
}
return false;
},
replaceAll:
() =>
({
state,
dispatch,
}: {
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
const searchAndReplaceState =
searchAndReplacePluginKey.getState(state);
if (!searchAndReplaceState) {
return false;
}
const { replaceTerm, results } = searchAndReplaceState;
const tr = state.tr;
let offset = 0;
results.forEach((result) => {
const { from, to } = result;
tr.insertText(replaceTerm, offset + from, offset + to);
// when performing multi-text replacement, it is necessary
// to calculate the offset between 'form' and 'to'.
offset = offset + replaceTerm.length - (to - from);
});
if (dispatch) {
dispatch(tr);
}
return false;
},
findNext:
() =>
({
state,
dispatch,
}: {
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
if (dispatch) {
const tr = state.tr;
const searchAndReplaceState =
searchAndReplacePluginKey.getState(state);
if (!searchAndReplaceState) {
return false;
}
const { findIndex } = searchAndReplaceState;
tr.setMeta(searchAndReplacePluginKey, {
setFindIndex: findIndex + 1,
});
dispatch(tr);
}
return false;
},
findPrevious:
() =>
({
state,
dispatch,
}: {
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
if (dispatch) {
const searchAndReplaceState =
searchAndReplacePluginKey.getState(state);
if (!searchAndReplaceState) {
return false;
}
const { findIndex } = searchAndReplaceState;
const tr = state.tr;
tr.setMeta(searchAndReplacePluginKey, {
setFindIndex: findIndex - 1,
});
dispatch(tr);
}
return false;
},
openSearch:
() =>
({
state,
dispatch,
}: {
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
const searchAndReplaceState =
searchAndReplacePluginKey.getState(state);
if (!searchAndReplaceState) {
return false;
}
const searchAndReplaceInstance = instance.component;
if (searchAndReplaceInstance) {
searchAndReplaceInstance.props.visible = true;
const tr = state.tr;
tr.setMeta(searchAndReplacePluginKey, {
setEnable: true,
});
if (dispatch) {
dispatch(tr);
}
}
return false;
},
closeSearch:
() =>
({
state,
dispatch,
}: {
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
const searchAndReplaceState =
searchAndReplacePluginKey.getState(state);
if (!searchAndReplaceState) {
return false;
}
const searchAndReplaceInstance = instance.component;
if (searchAndReplaceInstance) {
searchAndReplaceInstance.props.visible = false;
const tr = state.tr;
tr.setMeta(searchAndReplacePluginKey, {
setEnable: false,
});
if (dispatch) {
dispatch(tr);
}
}
return false;
},
};
},
addProseMirrorPlugins() {
const containerDom = document.createElement("div");
containerDom.style.position = "sticky";
containerDom.style.top = "0";
containerDom.style.zIndex = "50";
instance.props = {
editor: this.editor,
pluginKey: searchAndReplacePluginKey,
visible: false,
};
render(instance, containerDom);
return [
SearchAndReplacePlugin({
editor: this.editor as Editor,
element: containerDom,
}),
];
},
addKeyboardShortcuts() {
return {
"Mod-f": () => {
this.editor.commands.openSearch();
return true;
},
};
},
});
export default SearchAndReplace;

View File

@ -69,6 +69,20 @@ editor:
add_column_before: Add column before
add_column_after: Add column after
delete_column: Delete column
search_and_replace:
title: Search and Replace
search_placeholder: Search
not_found: Not Found
occurrence_found: "{index} of {total} occurrences"
find_previous: Find Previous
find_next: Find Next
replace_placeholder: Replace
replace: Replace
replace_all: Replace All
case_sensitive: Case Sensitive
match_word: Match Whole Word
use_regex: Use Regular Expression
close: Close
components:
color_picker:
more_color: More

View File

@ -69,6 +69,20 @@ editor:
add_column_before: 向前插入列
add_column_after: 向后插入列
delete_column: 删除当前列
search_and_replace:
title: 查找替换
search_placeholder: 查找
not_found: 无结果
occurrence_found: 第 {index} 项,共 {total} 项
find_previous: 上一个匹配项
find_next: 下一个匹配项
replace_placeholder: 替换
replace: 替换
replace_all: 全部替换
case_sensitive: 区分大小写
match_word: 全字匹配
use_regex: 使用正则表达式
close: 关闭
components:
color_picker:
more_color: 更多颜色

View File

@ -2,3 +2,4 @@
@import "./table.scss";
@import "./draggable.scss";
@import "./columns.scss";
@import "./search.scss";

View File

@ -0,0 +1,11 @@
.halo-rich-text-editor {
.ProseMirror {
.search-result {
background-color: #ffd90050;
&.search-result-current {
background-color: #ffd900;
}
}
}
}

View File

@ -1,3 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* override @tailwindcss/forms styles */
input[type="text"],
input[type="password"],
input[type="email"],
input[type="number"],
input[type="url"],
input[type="date"],
input[type="datetime-local"],
input[type="month"],
input[type="week"],
input[type="time"],
input[type="search"],
input[type="tel"],
input:where(:not([type])),
select,
textarea {
@apply border-none py-0 focus:outline-0 focus:ring-0;
}
input[type="checkbox"] {
@apply rounded-sm border-gray-500;
}

View File

@ -49,6 +49,7 @@ import {
PluginKey,
DecorationSet,
ExtensionListKeymap,
ExtensionSearchAndReplace,
} from "@halo-dev/richtext-editor";
// ui custom extension
import { UiExtensionImage, UiExtensionUpload } from "./extensions";
@ -388,6 +389,7 @@ onMounted(() => {
}),
ExtensionListKeymap,
UiExtensionUpload,
ExtensionSearchAndReplace,
],
autofocus: "start",
onUpdate: () => {