mirror of https://github.com/halo-dev/halo
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
parent
ddbc73b079
commit
38465253c8
|
@ -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>
|
||||
|
|
|
@ -30,6 +30,7 @@ withDefaults(
|
|||
]"
|
||||
class="p-1 rounded-sm"
|
||||
:disabled="disabled"
|
||||
tabindex="-1"
|
||||
@click="action"
|
||||
>
|
||||
<component :is="icon" />
|
||||
|
|
|
@ -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() + "";
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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: 更多颜色
|
||||
|
|
|
@ -2,3 +2,4 @@
|
|||
@import "./table.scss";
|
||||
@import "./draggable.scss";
|
||||
@import "./columns.scss";
|
||||
@import "./search.scss";
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
.halo-rich-text-editor {
|
||||
.ProseMirror {
|
||||
.search-result {
|
||||
background-color: #ffd90050;
|
||||
|
||||
&.search-result-current {
|
||||
background-color: #ffd900;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: () => {
|
||||
|
|
Loading…
Reference in New Issue