diff --git a/console/packages/editor/src/components/EditorHeader.vue b/console/packages/editor/src/components/EditorHeader.vue index 19615242dc..a22c3f502b 100644 --- a/console/packages/editor/src/components/EditorHeader.vue +++ b/console/packages/editor/src/components/EditorHeader.vue @@ -63,7 +63,7 @@ function getToolboxItemsFromExtensions() { >
- @@ -90,9 +91,10 @@ function getToolboxItemsFromExtensions() { :is="item.component" v-if="!item.children?.length" v-bind="item.props" + tabindex="-1" /> - - + + diff --git a/console/packages/editor/src/components/toolbar/ToolbarItem.vue b/console/packages/editor/src/components/toolbar/ToolbarItem.vue index 94d508f3b6..04e769f82c 100644 --- a/console/packages/editor/src/components/toolbar/ToolbarItem.vue +++ b/console/packages/editor/src/components/toolbar/ToolbarItem.vue @@ -30,6 +30,7 @@ withDefaults( ]" class="p-1 rounded-sm" :disabled="disabled" + tabindex="-1" @click="action" > diff --git a/console/packages/editor/src/dev/App.vue b/console/packages/editor/src/dev/App.vue index 61c22079a4..a8f26cd8f3 100644 --- a/console/packages/editor/src/dev/App.vue +++ b/console/packages/editor/src/dev/App.vue @@ -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() + ""; diff --git a/console/packages/editor/src/extensions/index.ts b/console/packages/editor/src/extensions/index.ts index 17befbc9d8..66c9d93236 100644 --- a/console/packages/editor/src/extensions/index.ts +++ b/console/packages/editor/src/extensions/index.ts @@ -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, }; diff --git a/console/packages/editor/src/extensions/search-and-replace/SearchAndReplace.vue b/console/packages/editor/src/extensions/search-and-replace/SearchAndReplace.vue new file mode 100644 index 0000000000..986238451c --- /dev/null +++ b/console/packages/editor/src/extensions/search-and-replace/SearchAndReplace.vue @@ -0,0 +1,354 @@ + + + diff --git a/console/packages/editor/src/extensions/search-and-replace/SearchAndReplacePlugin.ts b/console/packages/editor/src/extensions/search-and-replace/SearchAndReplacePlugin.ts new file mode 100644 index 0000000000..d1a8e914e8 --- /dev/null +++ b/console/packages/editor/src/extensions/search-and-replace/SearchAndReplacePlugin.ts @@ -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("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; + }, + }, + }); +}; diff --git a/console/packages/editor/src/extensions/search-and-replace/index.ts b/console/packages/editor/src/extensions/search-and-replace/index.ts new file mode 100644 index 0000000000..5389ee6c0c --- /dev/null +++ b/console/packages/editor/src/extensions/search-and-replace/index.ts @@ -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 { + 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(SearchAndReplaceVue); +function isShowSearch() { + const searchAndReplaceInstance = instance.component; + if (searchAndReplaceInstance) { + return searchAndReplaceInstance.props.visible; + } + return false; +} +const SearchAndReplace = Extension.create({ + 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; diff --git a/console/packages/editor/src/locales/en.yaml b/console/packages/editor/src/locales/en.yaml index 34ea7a5668..ff8bc6692f 100644 --- a/console/packages/editor/src/locales/en.yaml +++ b/console/packages/editor/src/locales/en.yaml @@ -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 diff --git a/console/packages/editor/src/locales/zh-CN.yaml b/console/packages/editor/src/locales/zh-CN.yaml index 62efb689df..25e441247e 100644 --- a/console/packages/editor/src/locales/zh-CN.yaml +++ b/console/packages/editor/src/locales/zh-CN.yaml @@ -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: 更多颜色 diff --git a/console/packages/editor/src/styles/index.scss b/console/packages/editor/src/styles/index.scss index 4448c002cf..fe48243777 100644 --- a/console/packages/editor/src/styles/index.scss +++ b/console/packages/editor/src/styles/index.scss @@ -2,3 +2,4 @@ @import "./table.scss"; @import "./draggable.scss"; @import "./columns.scss"; +@import "./search.scss"; diff --git a/console/packages/editor/src/styles/search.scss b/console/packages/editor/src/styles/search.scss new file mode 100644 index 0000000000..42539abdfb --- /dev/null +++ b/console/packages/editor/src/styles/search.scss @@ -0,0 +1,11 @@ +.halo-rich-text-editor { + .ProseMirror { + .search-result { + background-color: #ffd90050; + + &.search-result-current { + background-color: #ffd900; + } + } + } +} diff --git a/console/src/components/editor/DefaultEditor.vue b/console/src/components/editor/DefaultEditor.vue index 1375043005..cd4dfed201 100644 --- a/console/src/components/editor/DefaultEditor.vue +++ b/console/src/components/editor/DefaultEditor.vue @@ -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: () => {