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">
|
<div class="inline-flex items-center justify-center">
|
||||||
<VMenu>
|
<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]" />
|
<MdiPlusCircle class="text-[#4CCBA0]" />
|
||||||
</button>
|
</button>
|
||||||
<template #popper>
|
<template #popper>
|
||||||
|
@ -75,6 +75,7 @@ function getToolboxItemsFromExtensions() {
|
||||||
v-for="(toolboxItem, index) in getToolboxItemsFromExtensions()"
|
v-for="(toolboxItem, index) in getToolboxItemsFromExtensions()"
|
||||||
v-bind="toolboxItem.props"
|
v-bind="toolboxItem.props"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
tabindex="-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -90,9 +91,10 @@ function getToolboxItemsFromExtensions() {
|
||||||
:is="item.component"
|
:is="item.component"
|
||||||
v-if="!item.children?.length"
|
v-if="!item.children?.length"
|
||||||
v-bind="item.props"
|
v-bind="item.props"
|
||||||
|
tabindex="-1"
|
||||||
/>
|
/>
|
||||||
<VMenu v-else class="inline-flex">
|
<VMenu v-else class="inline-flex" tabindex="-1">
|
||||||
<component :is="item.component" v-bind="item.props" />
|
<component :is="item.component" v-bind="item.props" tabindex="-1" />
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<div
|
<div
|
||||||
class="relative rounded-md bg-white overflow-hidden drop-shadow w-48 p-1 max-h-72 overflow-y-auto"
|
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"
|
:is="child.component"
|
||||||
v-for="(child, childIndex) in item.children"
|
v-for="(child, childIndex) in item.children"
|
||||||
:key="childIndex"
|
:key="childIndex"
|
||||||
|
tabindex="-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -30,6 +30,7 @@ withDefaults(
|
||||||
]"
|
]"
|
||||||
class="p-1 rounded-sm"
|
class="p-1 rounded-sm"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
tabindex="-1"
|
||||||
@click="action"
|
@click="action"
|
||||||
>
|
>
|
||||||
<component :is="icon" />
|
<component :is="icon" />
|
||||||
|
|
|
@ -44,6 +44,7 @@ import {
|
||||||
ExtensionNodeSelected,
|
ExtensionNodeSelected,
|
||||||
ExtensionTrailingNode,
|
ExtensionTrailingNode,
|
||||||
ExtensionListKeymap,
|
ExtensionListKeymap,
|
||||||
|
ExtensionSearchAndReplace,
|
||||||
} from "../index";
|
} from "../index";
|
||||||
|
|
||||||
const content = useLocalStorage("content", "");
|
const content = useLocalStorage("content", "");
|
||||||
|
@ -109,6 +110,7 @@ const editor = useEditor({
|
||||||
ExtensionNodeSelected,
|
ExtensionNodeSelected,
|
||||||
ExtensionTrailingNode,
|
ExtensionTrailingNode,
|
||||||
ExtensionListKeymap,
|
ExtensionListKeymap,
|
||||||
|
ExtensionSearchAndReplace,
|
||||||
],
|
],
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
content.value = editor.value?.getHTML() + "";
|
content.value = editor.value?.getHTML() + "";
|
||||||
|
|
|
@ -42,6 +42,7 @@ import ExtensionText from "./text";
|
||||||
import ExtensionDraggable from "./draggable";
|
import ExtensionDraggable from "./draggable";
|
||||||
import ExtensionNodeSelected from "./node-selected";
|
import ExtensionNodeSelected from "./node-selected";
|
||||||
import ExtensionTrailingNode from "./trailing-node";
|
import ExtensionTrailingNode from "./trailing-node";
|
||||||
|
import ExtensionSearchAndReplace from "./search-and-replace";
|
||||||
|
|
||||||
const allExtensions = [
|
const allExtensions = [
|
||||||
ExtensionBlockquote,
|
ExtensionBlockquote,
|
||||||
|
@ -98,6 +99,7 @@ const allExtensions = [
|
||||||
ExtensionColumn,
|
ExtensionColumn,
|
||||||
ExtensionNodeSelected,
|
ExtensionNodeSelected,
|
||||||
ExtensionTrailingNode,
|
ExtensionTrailingNode,
|
||||||
|
ExtensionSearchAndReplace,
|
||||||
];
|
];
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -144,4 +146,5 @@ export {
|
||||||
ExtensionNodeSelected,
|
ExtensionNodeSelected,
|
||||||
ExtensionTrailingNode,
|
ExtensionTrailingNode,
|
||||||
ExtensionListKeymap,
|
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_before: Add column before
|
||||||
add_column_after: Add column after
|
add_column_after: Add column after
|
||||||
delete_column: Delete column
|
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:
|
components:
|
||||||
color_picker:
|
color_picker:
|
||||||
more_color: More
|
more_color: More
|
||||||
|
|
|
@ -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_placeholder: 查找
|
||||||
|
not_found: 无结果
|
||||||
|
occurrence_found: 第 {index} 项,共 {total} 项
|
||||||
|
find_previous: 上一个匹配项
|
||||||
|
find_next: 下一个匹配项
|
||||||
|
replace_placeholder: 替换
|
||||||
|
replace: 替换
|
||||||
|
replace_all: 全部替换
|
||||||
|
case_sensitive: 区分大小写
|
||||||
|
match_word: 全字匹配
|
||||||
|
use_regex: 使用正则表达式
|
||||||
|
close: 关闭
|
||||||
components:
|
components:
|
||||||
color_picker:
|
color_picker:
|
||||||
more_color: 更多颜色
|
more_color: 更多颜色
|
||||||
|
|
|
@ -2,3 +2,4 @@
|
||||||
@import "./table.scss";
|
@import "./table.scss";
|
||||||
@import "./draggable.scss";
|
@import "./draggable.scss";
|
||||||
@import "./columns.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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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,
|
PluginKey,
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
ExtensionListKeymap,
|
ExtensionListKeymap,
|
||||||
|
ExtensionSearchAndReplace,
|
||||||
} from "@halo-dev/richtext-editor";
|
} from "@halo-dev/richtext-editor";
|
||||||
// ui custom extension
|
// ui custom extension
|
||||||
import { UiExtensionImage, UiExtensionUpload } from "./extensions";
|
import { UiExtensionImage, UiExtensionUpload } from "./extensions";
|
||||||
|
@ -388,6 +389,7 @@ onMounted(() => {
|
||||||
}),
|
}),
|
||||||
ExtensionListKeymap,
|
ExtensionListKeymap,
|
||||||
UiExtensionUpload,
|
UiExtensionUpload,
|
||||||
|
ExtensionSearchAndReplace,
|
||||||
],
|
],
|
||||||
autofocus: "start",
|
autofocus: "start",
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
|
|
Loading…
Reference in New Issue