diff --git a/package-lock.json b/package-lock.json index 77ad6a510..aa3fa1a90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.24.1", "prosemirror-schema-list": "^1.5.0", + "prosemirror-search": "^1.1.0", "prosemirror-state": "^1.4.3", "prosemirror-test-builder": "^1.1.1", "prosemirror-transform": "^1.10.2", @@ -18745,6 +18746,17 @@ "prosemirror-transform": "^1.7.3" } }, + "node_modules/prosemirror-search": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/prosemirror-search/-/prosemirror-search-1.1.0.tgz", + "integrity": "sha512-hnGINlrRs+St6scaF4hoGiR8b7V0ffddzvO/zy+ON8RwvVinfLk4rVsuSztLNthgvfE2LAOU4blsPr7yoeoLOQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0", + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.33.6" + } + }, "node_modules/prosemirror-state": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", diff --git a/package.json b/package.json index 97aae8b70..10486eba4 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,7 @@ "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.24.1", "prosemirror-schema-list": "^1.5.0", + "prosemirror-search": "^1.1.0", "prosemirror-state": "^1.4.3", "prosemirror-test-builder": "^1.1.1", "prosemirror-transform": "^1.10.2", diff --git a/src/bundle/MarkdownEditorView.tsx b/src/bundle/MarkdownEditorView.tsx index 6f3f0055d..a08b94162 100644 --- a/src/bundle/MarkdownEditorView.tsx +++ b/src/bundle/MarkdownEditorView.tsx @@ -420,7 +420,7 @@ MarkdownEditorView.displayName = 'MarkdownEditorView'; interface MarkupSearchAnchorProps extends Pick {} const MarkupSearchAnchor: React.FC = ({mode}) => ( - <>{mode === 'markup' &&
} +
); function Settings(props: EditorSettingsProps & {stickyToolbar: boolean}) { diff --git a/src/bundle/types.ts b/src/bundle/types.ts index 60c13fbb0..de5ab63ac 100644 --- a/src/bundle/types.ts +++ b/src/bundle/types.ts @@ -190,6 +190,11 @@ export type MarkdownEditorWysiwygConfig = { * Note: The use of the markdown-it-attrs plugin will be removed in the next major version. */ disableMarkdownAttrs?: boolean; + /** + * Show search panel in the editor. + * @default true + */ + searchPanel?: boolean; }; export type MarkdownEditorOptions = { diff --git a/src/bundle/useMarkdownEditor.ts b/src/bundle/useMarkdownEditor.ts index 09082eca7..86be1b4aa 100644 --- a/src/bundle/useMarkdownEditor.ts +++ b/src/bundle/useMarkdownEditor.ts @@ -67,6 +67,7 @@ export function useMarkdownEditor( experimental.needToSetDimensionsForUploadedImages, enableNewImageSizeCalculation: experimental.enableNewImageSizeCalculation, mobile, + searchPanel: wysiwygConfig.searchPanel ?? true, }); { const extraExtensions = wysiwygConfig.extensions; diff --git a/src/bundle/wysiwyg-preset.ts b/src/bundle/wysiwyg-preset.ts index 874ba18d1..7bdcb2d37 100644 --- a/src/bundle/wysiwyg-preset.ts +++ b/src/bundle/wysiwyg-preset.ts @@ -43,6 +43,7 @@ export type BundlePresetOptions = ExtensionsOptions & // MAJOR: remove markdown-it-attrs disableMdAttrs?: boolean; mobile?: boolean; + searchPanel: boolean; }; declare global { @@ -73,6 +74,7 @@ export const BundlePreset: ExtensionAuto = (builder, opts) }, }, cursor: {dropOptions: dropCursor}, + search: opts.searchPanel ? {anchorSelector: '.g-md-search-wysiwyg-anchor'} : undefined, clipboard: {pasteFileHandler: opts.fileUploadHandler, ...opts.clipboard}, selectionContext: {config: wSelectionMenuConfigByPreset.zero, ...opts.selectionContext}, commandMenu: {actions: wCommandMenuConfigByPreset.zero, ...opts.commandMenu}, diff --git a/src/extensions/behavior/Search/Search.ts b/src/extensions/behavior/Search/Search.ts new file mode 100644 index 000000000..b30484034 --- /dev/null +++ b/src/extensions/behavior/Search/Search.ts @@ -0,0 +1,11 @@ +import {search} from 'prosemirror-search'; + +import type {ExtensionAuto} from '#core'; + +import {type SearchViewPluginParams, searchViewPlugin} from './SearchViewPlugin'; + +export type SearchOptions = Pick; + +export const Search: ExtensionAuto = (builder, opts) => { + builder.addPlugin(() => [search(), searchViewPlugin(opts)]); +}; diff --git a/src/extensions/behavior/Search/SearchViewPlugin.ts b/src/extensions/behavior/Search/SearchViewPlugin.ts new file mode 100644 index 000000000..5351bcf72 --- /dev/null +++ b/src/extensions/behavior/Search/SearchViewPlugin.ts @@ -0,0 +1,187 @@ +import {SearchQuery, getSearchState, setSearchState} from 'prosemirror-search'; + +import {type Command, Plugin, type PluginView} from '#pm/state'; +import type {EditorView} from '#pm/view'; +import {type SearchCounter, type SearchState, renderSearchPopup} from 'src/modules/search'; + +import {getReactRendererFromState} from '../ReactRenderer'; + +import {closeSearch, findNext, findPrev, replaceAll, replaceNext} from './commands'; +import {pluginKey} from './const'; +import {searchKeyHandler} from './key-handler'; +import type {SearchViewState} from './types'; +import {startTracking} from './utils/connect-tracker'; +import {FocusManager} from './utils/focus-manager'; +import {getCounter} from './utils/search-counter'; + +import './search-plugin.scss'; + +export interface SearchViewPluginParams { + anchorSelector: string; +} + +export const searchViewPlugin = (params: SearchViewPluginParams) => { + return new Plugin({ + key: pluginKey, + props: { + handleKeyDown: searchKeyHandler, + }, + state: { + init: () => ({open: false}), + apply(tr, value, _oldState, _newState) { + const newValue = tr.getMeta(pluginKey) as SearchViewState | undefined; + if (typeof newValue === 'object') return newValue; + return value; + }, + }, + view(view) { + return new SeachPluginView(view, params); + }, + }); +}; + +class SeachPluginView implements PluginView { + private readonly _view: EditorView; + private readonly _renderer; + private readonly _focusManager: FocusManager; + private readonly _viewDomTrackerDispose; + + private _counter: SearchCounter; + private _isDomConnected: boolean; + private _viewState: SearchViewState | undefined; + private _searchState: SearchQuery | undefined; + + constructor(view: EditorView, params: SearchViewPluginParams) { + this._view = view; + this._viewState = pluginKey.getState(view.state); + this._searchState = getSearchState(view.state)?.query; + this._counter = getCounter(view.state); + this._isDomConnected = view.dom.ownerDocument.contains(view.dom); + + this._renderer = this._createRenderer(params); + this._focusManager = new FocusManager(view.dom.ownerDocument); + + // uses MutationObserver to detect when view.dom is disconnected from the DOM tree + // TODO: replace with eventBus (subscribe to change-editor-mode event) to track when to hide the search bar + // see https://github.com/gravity-ui/markdown-editor/issues/884 + this._viewDomTrackerDispose = startTracking(view.dom, { + onConnect: this._onEditorViewDomConnected, + onDisconnect: this._onEditorViewDomDisconnected, + }); + } + + update() { + const newCounter = getCounter(this._view.state); + const newViewState = pluginKey.getState(this._view.state); + const newSearchState = getSearchState(this._view.state)?.query; + + if ( + newViewState !== this._viewState || + newSearchState !== this._searchState || + newCounter.total !== this._counter.total || + newCounter.current !== this._counter.current + ) { + if ( + newSearchState !== this._searchState && + newCounter.current === 0 && + newCounter.total > 0 + ) { + // after changing the search query and if no match is selected, move the selection to the next match + (window.requestAnimationFrame || window.setTimeout)(() => { + this._preserveFocus(findNext); + }); + } + + this._counter = newCounter; + this._viewState = newViewState; + this._searchState = newSearchState; + this._renderer.rerender(); + } + } + + destroy() { + this._renderer.remove(); + this._viewDomTrackerDispose(); + } + + private _onEditorViewDomConnected = () => { + this._isDomConnected = true; + this._renderer.rerender(); + }; + + private _onEditorViewDomDisconnected = () => { + this._isDomConnected = false; + this._onClose(); + }; + + private _createRenderer(params: Pick) { + return getReactRendererFromState(this._view.state).createItem('search-view', () => { + const { + _viewState: viewState, + _searchState: searchState, + _isDomConnected: domConnected, + } = this; + + if (!domConnected || !viewState?.open || !searchState) return null; + + const anchor = this._view.dom.ownerDocument.querySelector(params.anchorSelector); + + return renderSearchPopup({ + anchor, + open: viewState.open, + counter: this._counter, + state: searchState, + onClose: this._onClose, + onChange: this._onChange, + onSearchPrev: this._onSearchPrev, + onSearchNext: this._onSearchNext, + onReplaceNext: this._onReplaceNext, + onReplaceAll: this._onReplaceAll, + }); + }); + } + + private _onChange = (config: SearchState) => { + const {state, dispatch} = this._view; + + dispatch( + setSearchState( + state.tr, + new SearchQuery({ + search: config.search, + replace: config.replace, + caseSensitive: config.caseSensitive, + wholeWord: config.wholeWord, + }), + ), + ); + }; + + private _onClose = () => { + closeSearch(this._view.state, this._view.dispatch); + this._view.focus(); + }; + + private _onSearchPrev = () => { + this._preserveFocus(findPrev); + }; + + private _onSearchNext = () => { + this._preserveFocus(findNext); + }; + + private _onReplaceNext = () => { + this._preserveFocus(replaceNext); + }; + + private _onReplaceAll = () => { + this._preserveFocus(replaceAll); + }; + + private _preserveFocus(command: Command) { + this._focusManager.storeFocus(); + this._view.focus(); + command(this._view.state, this._view.dispatch, this._view); + this._focusManager.restoreFocus({preventScroll: true}); + } +} diff --git a/src/extensions/behavior/Search/commands.ts b/src/extensions/behavior/Search/commands.ts new file mode 100644 index 000000000..f41184eb6 --- /dev/null +++ b/src/extensions/behavior/Search/commands.ts @@ -0,0 +1,57 @@ +import { + SearchQuery, + findNext as findNextSearch, + findPrev as findPrevSearch, + getSearchState, + replaceAll as replaceAllSearch, + replaceNext as replaceNextSearch, + setSearchState, +} from 'prosemirror-search'; + +import type {Command} from '#pm/state'; + +import {hideSelectionMenu} from '../SelectionContext'; + +import {pluginKey} from './const'; +import type {SearchViewState} from './types'; +import {wrapCommand} from './utils/wrap-command'; + +export const findNext = wrapCommand(findNextSearch, hideSelectionMenu); +export const findPrev = wrapCommand(findPrevSearch, hideSelectionMenu); +export const replaceNext = wrapCommand(replaceNextSearch, hideSelectionMenu); +export const replaceAll = wrapCommand(replaceAllSearch, hideSelectionMenu); + +export const openSearch: Command = (state, dispatch) => { + if (dispatch) { + const searchState = getSearchState(state); + const search = state.doc.textBetween(state.selection.from, state.selection.to, ' '); + const meta: SearchViewState = {open: true}; + dispatch( + setSearchState( + hideSelectionMenu(state.tr.setMeta(pluginKey, meta)), + new SearchQuery({ + ...(searchState + ? { + regexp: searchState.query.regexp, + replace: searchState.query.replace, + literal: searchState.query.literal, + wholeWord: searchState.query.wholeWord, + caseSensitive: searchState.query.caseSensitive, + filter: searchState.query.filter || undefined, + } + : undefined), + search, + }), + ), + ); + } + return true; +}; + +export const closeSearch: Command = (state, dispatch) => { + if (dispatch) { + const meta: SearchViewState = {open: false}; + dispatch(setSearchState(state.tr.setMeta(pluginKey, meta), new SearchQuery({search: ''}))); + } + return true; +}; diff --git a/src/extensions/behavior/Search/const.ts b/src/extensions/behavior/Search/const.ts new file mode 100644 index 000000000..304354e4b --- /dev/null +++ b/src/extensions/behavior/Search/const.ts @@ -0,0 +1,10 @@ +import {PluginKey} from '#pm/state'; + +import type {SearchViewState} from './types'; + +export const SearchClassName = { + Match: 'ProseMirror-search-match', + ActiveMatch: 'ProseMirror-active-search-match', +} as const; + +export const pluginKey = new PluginKey('search-view'); diff --git a/src/extensions/behavior/Search/index.ts b/src/extensions/behavior/Search/index.ts new file mode 100644 index 000000000..addd53308 --- /dev/null +++ b/src/extensions/behavior/Search/index.ts @@ -0,0 +1 @@ +export * from './Search'; diff --git a/src/extensions/behavior/Search/key-handler.ts b/src/extensions/behavior/Search/key-handler.ts new file mode 100644 index 000000000..bce503f9d --- /dev/null +++ b/src/extensions/behavior/Search/key-handler.ts @@ -0,0 +1,12 @@ +import {keydownHandler} from '#pm/keymap'; + +import {closeSearch, findNext, findPrev, openSearch} from './commands'; + +export const searchKeyHandler = keydownHandler({ + 'Mod-f': openSearch, + Escape: closeSearch, + F3: findNext, + 'Shift-F3': findPrev, + 'Mod-g': findNext, + 'Shift-Mod-g': findPrev, +}); diff --git a/src/extensions/behavior/Search/search-plugin.scss b/src/extensions/behavior/Search/search-plugin.scss new file mode 100644 index 000000000..1c58eb44b --- /dev/null +++ b/src/extensions/behavior/Search/search-plugin.scss @@ -0,0 +1,7 @@ +.ProseMirror-search-match { + background-color: var(--g-color-base-info-light); +} + +.ProseMirror-active-search-match { + background-color: var(--g-color-base-info-heavy); +} diff --git a/src/extensions/behavior/Search/types.ts b/src/extensions/behavior/Search/types.ts new file mode 100644 index 000000000..420608a30 --- /dev/null +++ b/src/extensions/behavior/Search/types.ts @@ -0,0 +1 @@ +export type SearchViewState = {open: boolean}; diff --git a/src/extensions/behavior/Search/utils/connect-tracker.ts b/src/extensions/behavior/Search/utils/connect-tracker.ts new file mode 100644 index 000000000..90099918b --- /dev/null +++ b/src/extensions/behavior/Search/utils/connect-tracker.ts @@ -0,0 +1,30 @@ +type TrackingOptions = { + onConnect?: () => void; + onDisconnect?: () => void; +}; + +type Dispose = () => void; + +export function startTracking(elem: HTMLElement, options: TrackingOptions): Dispose { + const document = elem.ownerDocument; + + let connected = document.contains(elem); + + const observer = new MutationObserver(() => { + if (connected) { + if (!document.contains(elem)) { + connected = false; + options.onDisconnect?.(); + } + } else if (document.contains(elem)) { + connected = true; + options.onConnect?.(); + } + }); + + observer.observe(document.body, {childList: true, subtree: true}); + + return () => { + observer.disconnect(); + }; +} diff --git a/src/extensions/behavior/Search/utils/focus-manager.ts b/src/extensions/behavior/Search/utils/focus-manager.ts new file mode 100644 index 000000000..aeb76d64e --- /dev/null +++ b/src/extensions/behavior/Search/utils/focus-manager.ts @@ -0,0 +1,16 @@ +export class FocusManager { + #doc: Document; + #previousFocused: HTMLElement | null = null; + + constructor(doc: Document = document) { + this.#doc = doc; + } + + storeFocus() { + this.#previousFocused = this.#doc.activeElement as HTMLElement | null; + } + + restoreFocus(options?: FocusOptions) { + this.#previousFocused?.focus?.(options); + } +} diff --git a/src/extensions/behavior/Search/utils/search-counter.ts b/src/extensions/behavior/Search/utils/search-counter.ts new file mode 100644 index 000000000..2c6d58c7e --- /dev/null +++ b/src/extensions/behavior/Search/utils/search-counter.ts @@ -0,0 +1,19 @@ +import {getMatchHighlights} from 'prosemirror-search'; + +import type {EditorState} from '#pm/state'; +import type {SearchCounter} from 'src/modules/search'; + +import {SearchClassName} from '../const'; + +export function getCounter(state: EditorState): SearchCounter { + const decoSet = getMatchHighlights(state); + const searchDecos = decoSet.find(undefined, undefined, () => true); + const activeIndex = searchDecos.findIndex((deco) => { + const d = deco as {type?: {attrs?: Record}}; + return d.type?.attrs?.class === SearchClassName.ActiveMatch; + }); + return { + total: searchDecos.length, + current: activeIndex + 1, + }; +} diff --git a/src/extensions/behavior/Search/utils/wrap-command.ts b/src/extensions/behavior/Search/utils/wrap-command.ts new file mode 100644 index 000000000..9d0e62704 --- /dev/null +++ b/src/extensions/behavior/Search/utils/wrap-command.ts @@ -0,0 +1,20 @@ +import type {Command, Transaction} from '#pm/state'; + +type Wrapper = (tr: Transaction) => void; + +export function wrapCommand(command: Command, ...wrappers: [Wrapper, ...Wrapper[]]): Command { + return (state, dispatch, view) => { + return command( + state, + dispatch + ? function dispatchWithWrappers(tr) { + for (const wrapper of wrappers) { + wrapper(tr); + } + return dispatch(tr); + } + : undefined, + view, + ); + }; +} diff --git a/src/extensions/behavior/SelectionContext/index.ts b/src/extensions/behavior/SelectionContext/index.ts index c342a1f8f..46afc6412 100644 --- a/src/extensions/behavior/SelectionContext/index.ts +++ b/src/extensions/behavior/SelectionContext/index.ts @@ -4,8 +4,11 @@ import { AllSelection, type EditorState, Plugin, + PluginKey, type PluginSpec, + type StateField, TextSelection, + type Transaction, } from 'prosemirror-state'; // @ts-ignore // TODO: fix cjs build import {hasParentNode} from 'prosemirror-utils'; @@ -47,9 +50,21 @@ export const SelectionContext: ExtensionAuto = (builder } }; +const HideMetaKey = 'hide-selection-menu'; + +export const hideSelectionMenu = (tr: Transaction) => { + return tr.setMeta(HideMetaKey, true); +}; + +const pluginKey = new PluginKey('selection-context'); + +type PluginState = { + disabled: boolean; +}; + type TinyState = Pick; -class SelectionTooltip implements PluginSpec { +class SelectionTooltip implements PluginSpec { private destroyed = false; private tooltip: TooltipView; @@ -66,6 +81,10 @@ class SelectionTooltip implements PluginSpec { this.tooltip = new TooltipView(actions, menuConfig, logger, options); } + get key(): PluginKey { + return pluginKey; + } + get props(): EditorProps { return { // same as keymap({}) @@ -101,6 +120,15 @@ class SelectionTooltip implements PluginSpec { }; } + get state(): StateField { + return { + init: () => ({disabled: false}), + apply(tr) { + return {disabled: Boolean(tr.getMeta(HideMetaKey))}; + }, + }; + } + view(view: EditorView) { this.update(view); return { @@ -118,9 +146,11 @@ class SelectionTooltip implements PluginSpec { this.cancelTooltipHiding(); + const hideFromTr = pluginKey.getState(view.state)?.disabled; + // Don't show tooltip if editor not mounted to the DOM // or when view is out of focus - if (!view.dom.parentNode || !view.hasFocus()) { + if (hideFromTr || !view.dom.parentNode || !view.hasFocus()) { this.tooltip.hide(view); return; } diff --git a/src/extensions/behavior/index.ts b/src/extensions/behavior/index.ts index aa8b5b9b3..bde2dfd05 100644 --- a/src/extensions/behavior/index.ts +++ b/src/extensions/behavior/index.ts @@ -9,6 +9,7 @@ import {FilePaste} from './FilePaste'; import {History, type HistoryOptions} from './History'; import {Placeholder} from './Placeholder'; import {type ReactRenderer, ReactRendererExtension} from './ReactRenderer'; +import {Search, type SearchOptions} from './Search'; import {Selection} from './Selection'; import {SelectionContext, type SelectionContextOptions} from './SelectionContext'; import {SharedState} from './SharedState'; @@ -35,7 +36,7 @@ export type BehaviorPresetOptions = { placeholder?: PlaceholderOptions; reactRenderer: ReactRenderer; selectionContext?: SelectionContextOptions; - + search?: SearchOptions; commandMenu?: CommandMenuOptions; mobile?: boolean; }; @@ -54,6 +55,7 @@ export const BehaviorPreset: ExtensionAuto = (builder, op if (!opts.mobile) { builder.use(SelectionContext, opts.selectionContext ?? {}); if (opts.commandMenu) builder.use(CommandMenu, opts.commandMenu); + if (opts.search) builder.use(Search, opts.search); } builder.use(FilePaste); diff --git a/src/i18n/search/en.json b/src/i18n/search/en.json index 3d03107b0..c122148af 100644 --- a/src/i18n/search/en.json +++ b/src/i18n/search/en.json @@ -1,8 +1,15 @@ { "label_case-sensitive": "Case sensitive", "label_whole-word": "Whole word", - "title": "Search in code", + "title": "Search and replace", + "action_close": "Close", "action_replace": "Replace", "action_replace_all": "Replace all", - "replace_placeholder": "Replacement text" + "action_next": "Find next", + "action_prev": "Find previous", + "action_expand": "Expand the replacement form", + "title_search": "Find", + "title_replace": "Replace with", + "search_counter": "{{current}} of {{total}}", + "search_placeholder": "Text search" } \ No newline at end of file diff --git a/src/i18n/search/ru.json b/src/i18n/search/ru.json index 2d4a5d001..32df7cc31 100644 --- a/src/i18n/search/ru.json +++ b/src/i18n/search/ru.json @@ -1,8 +1,15 @@ { "label_case-sensitive": "С учетом регистра", - "label_whole-word": "Слово целиком", - "title": "Найти в коде", + "label_whole-word": "Точное совпадение", + "title": "Поиск и замена", + "action_close": "Закрыть", "action_replace": "Заменить", "action_replace_all": "Заменить всё", - "replace_placeholder": "Текст замены" + "action_next": "Следующий", + "action_prev": "Предыдущий", + "action_expand": "Раскрыть окно замены", + "title_search": "Найти", + "title_replace": "Заменить на", + "search_counter": "{{current}} из {{total}}", + "search_placeholder": "Поиск по тексту" } \ No newline at end of file diff --git a/src/markup/codemirror/create.ts b/src/markup/codemirror/create.ts index 9e984f948..a1452f942 100644 --- a/src/markup/codemirror/create.ts +++ b/src/markup/codemirror/create.ts @@ -293,7 +293,7 @@ export function createCodemirror(params: CreateCodemirrorParams) { if (searchPanel) { extensions.push( SearchPanelPlugin({ - anchorSelector: '.g-md-search-anchor', + anchorSelector: '.g-md-search-markup-anchor', receiver, }), ); diff --git a/src/markup/codemirror/search-plugin/plugin.ts b/src/markup/codemirror/search-plugin/plugin.ts index 86dd5b775..068f8e02a 100644 --- a/src/markup/codemirror/search-plugin/plugin.ts +++ b/src/markup/codemirror/search-plugin/plugin.ts @@ -19,19 +19,18 @@ import { keymap, } from '@codemirror/view'; -import type {MarkdownEditorMode} from '../../../bundle'; -import type {EventMap} from '../../../bundle/Editor'; -import type {RendererItem} from '../../../extensions'; -import {debounce} from '../../../lodash'; -import type {Receiver} from '../../../utils'; +import type {MarkdownEditorMode} from 'src/bundle'; +import type {EventMap} from 'src/bundle/Editor'; +import type {RendererItem} from 'src/extensions'; +import {renderSearchPopup} from 'src/modules/search'; +import type {Receiver} from 'src/utils'; + import {ReactRendererFacet} from '../react-facet'; -import {renderSearchPopup} from './view/SearchPopup'; +import {searchTheme} from './theme'; type SearchQueryConfig = ConstructorParameters[0]; -const INPUT_DELAY = 200; - export interface SearchPanelPluginParams { anchorSelector: string; inputDelay?: number; @@ -44,85 +43,70 @@ export const SearchPanelPlugin = (params: SearchPanelPluginParams) => readonly view: EditorView; readonly params: SearchPanelPluginParams; - anchor: HTMLElement | null; - renderer: RendererItem | null; - searchConfig: SearchQueryConfig = { - search: '', - caseSensitive: false, - wholeWord: false, - replace: '', - }; + renderer: RendererItem; receiver: Receiver | undefined; - setViewSearchWithDelay: (config: Partial) => void; + panelOpened: boolean; + searchState: SearchQuery | null; constructor(view: EditorView) { this.view = view; - this.anchor = null; - this.renderer = null; this.params = params; this.receiver = params.receiver; + this.panelOpened = searchPanelOpen(view.state); + this.searchState = getSearchQuery(view.state); + this.renderer = this.createRenderer(); + this.handleClose = this.handleClose.bind(this); this.handleChange = this.handleChange.bind(this); this.handleSearchNext = this.handleSearchNext.bind(this); this.handleSearchPrev = this.handleSearchPrev.bind(this); this.handleReplaceNext = this.handleReplaceNext.bind(this); this.handleReplaceAll = this.handleReplaceAll.bind(this); - this.handleSearchConfigChange = this.handleSearchConfigChange.bind(this); this.handleEditorModeChange = this.handleEditorModeChange.bind(this); - this.setViewSearchWithDelay = debounce( - this.setViewSearch, - this.params.inputDelay ?? INPUT_DELAY, - ); this.receiver?.on('change-editor-mode', this.handleEditorModeChange); } update(update: ViewUpdate): void { const isPanelOpen = searchPanelOpen(update.state); + const searchQuery = getSearchQuery(update.state); - if (isPanelOpen && !this.renderer) { - const initial = getSearchQuery(update.state); - this.anchor = document.querySelector(this.params.anchorSelector); - this.renderer = this.view.state - .facet(ReactRendererFacet) - .createItem('cm-search', () => - renderSearchPopup({ - initial, - open: true, - anchor: this.anchor, - onChange: this.handleChange, - onClose: this.handleClose, - onSearchNext: this.handleSearchNext, - onSearchPrev: this.handleSearchPrev, - onReplaceNext: this.handleReplaceNext, - onReplaceAll: this.handleReplaceAll, - onConfigChange: this.handleSearchConfigChange, - }), - ); - } else if (!isPanelOpen && this.renderer) { - this.renderer?.remove(); - this.renderer = null; + if (isPanelOpen !== this.panelOpened || searchQuery !== this.searchState) { + this.panelOpened = isPanelOpen; + this.searchState = searchQuery; + this.renderer.rerender(); } } destroy() { - this.renderer?.remove(); - this.renderer = null; + this.renderer.remove(); this.receiver?.off('change-editor-mode', this.handleEditorModeChange); } - setViewSearch(config: Partial) { - this.searchConfig = { - ...this.searchConfig, - ...config, - }; - const searchQuery = new SearchQuery({ - ...this.searchConfig, + createRenderer() { + return this.view.state.facet(ReactRendererFacet).createItem('cm-search', () => { + if (!this.panelOpened || !this.searchState) return null; + + const anchor = this.view.dom.ownerDocument.querySelector( + this.params.anchorSelector, + ); + + if (!anchor) return null; + + return renderSearchPopup({ + open: true, + anchor: anchor, + state: this.searchState, + onClose: this.handleClose, + onChange: this.handleChange, + onSearchNext: this.handleSearchNext, + onSearchPrev: this.handleSearchPrev, + onReplaceNext: this.handleReplaceNext, + onReplaceAll: this.handleReplaceAll, + }); }); - - this.view.dispatch({effects: setSearchQuery.of(searchQuery)}); } handleEditorModeChange({mode}: {mode: MarkdownEditorMode}) { @@ -131,13 +115,23 @@ export const SearchPanelPlugin = (params: SearchPanelPluginParams) => } } - handleChange(search: string) { - this.setViewSearchWithDelay({search}); + handleChange(config: SearchQueryConfig) { + this.view.dispatch({ + effects: setSearchQuery.of( + new SearchQuery({ + search: config.search, + replace: config.replace, + caseSensitive: config.caseSensitive, + wholeWord: config.wholeWord, + }), + ), + }); } handleClose() { - this.setViewSearch({search: ''}); + this.handleChange({search: ''}); closeSearchPanel(this.view); + this.view.focus(); } handleSearchNext() { @@ -148,23 +142,18 @@ export const SearchPanelPlugin = (params: SearchPanelPluginParams) => findPrevious(this.view); } - handleSearchConfigChange(config: Partial) { - this.setViewSearch(config); - } - - handleReplaceNext(query: string, replacement: string) { - this.setViewSearch({search: query, replace: replacement}); + handleReplaceNext() { replaceNext(this.view); } - handleReplaceAll(query: string, replacement: string) { - this.setViewSearch({search: query, replace: replacement}); + handleReplaceAll() { replaceAll(this.view); } }, { provide: () => [ keymap.of(searchKeymap), + searchTheme, search({ createPanel: () => ({ // Create an empty search panel diff --git a/src/markup/codemirror/search-plugin/theme.ts b/src/markup/codemirror/search-plugin/theme.ts new file mode 100644 index 000000000..4508fab19 --- /dev/null +++ b/src/markup/codemirror/search-plugin/theme.ts @@ -0,0 +1,12 @@ +import {EditorView} from '#cm/view'; + +export const searchTheme = EditorView.baseTheme({ + '&light, &dark': { + '& .cm-searchMatch': { + backgroundColor: 'var(--g-color-base-info-light)', + }, + '& .cm-searchMatch-selected': { + backgroundColor: 'var(--g-color-base-info-heavy)', + }, + }, +}); diff --git a/src/markup/codemirror/search-plugin/view/ReplaceIcons.tsx b/src/markup/codemirror/search-plugin/view/ReplaceIcons.tsx deleted file mode 100644 index de8202838..000000000 --- a/src/markup/codemirror/search-plugin/view/ReplaceIcons.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type React from 'react'; - -export const ReplaceIcon: React.FC> = (props) => ( - - - -); - -export const ReplaceAllIcon: React.FC> = (props) => ( - - - -); diff --git a/src/markup/codemirror/search-plugin/view/SearchPopup.scss b/src/markup/codemirror/search-plugin/view/SearchPopup.scss deleted file mode 100644 index 743728f39..000000000 --- a/src/markup/codemirror/search-plugin/view/SearchPopup.scss +++ /dev/null @@ -1,12 +0,0 @@ -.g-md-search-card { - width: 450px; - padding: var(--g-spacing-2) var(--g-spacing-2) var(--g-spacing-3) var(--g-spacing-4); - - &__header { - display: flex; - justify-content: space-between; - align-items: center; - - margin-bottom: var(--g-spacing-1); - } -} diff --git a/src/markup/codemirror/search-plugin/view/SearchPopup.tsx b/src/markup/codemirror/search-plugin/view/SearchPopup.tsx deleted file mode 100644 index a58fd131e..000000000 --- a/src/markup/codemirror/search-plugin/view/SearchPopup.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import {useRef, useState} from 'react'; - -import type {SearchQuery} from '@codemirror/search'; -import {ChevronDown, ChevronUp, Xmark} from '@gravity-ui/icons'; -import { - Button, - Card, - Checkbox, - Icon, - Popup, - TextInput, - type TextInputProps, - sp, -} from '@gravity-ui/uikit'; - -import {cn} from '../../../../classname'; -import {i18n} from '../../../../i18n/search'; -import {enterKeyHandler} from '../../../../utils/handlers'; - -import {ReplaceAllIcon, ReplaceIcon} from './ReplaceIcons'; - -import './SearchPopup.scss'; - -type SearchInitial = Pick; -type SearchConfig = Pick; - -interface SearchCardProps { - initial: SearchInitial; - onSearchKeyDown?: (query: string) => void; - onChange?: (query: string) => void; - onClose?: (query: string) => void; - onSearchPrev?: (query: string) => void; - onSearchNext?: (query: string) => void; - onReplaceNext?: (query: string, replacement: string) => void; - onReplaceAll?: (query: string, replacement: string) => void; - onConfigChange?: (config: SearchConfig) => void; -} - -const b = cn('search-card'); - -const noop = () => {}; -const inverse = (val: boolean) => !val; - -export const SearchCard: React.FC = ({ - initial, - onChange = noop, - onClose = noop, - onSearchPrev = noop, - onSearchNext = noop, - onReplaceNext = noop, - onReplaceAll = noop, - onConfigChange = noop, -}) => { - const [query, setQuery] = useState(initial.search); - const [isCaseSensitive, setIsCaseSensitive] = useState(initial.caseSensitive); - const [isWholeWord, setIsWholeWord] = useState(initial.wholeWord); - const [replacement, setReplacement] = useState(''); - const textInputRef = useRef(null); - - const setInputFocus = () => { - textInputRef.current?.focus(); - }; - - const handleInputChange = (value: string) => { - setQuery(value); - onChange(value); - }; - - const handleClose = () => { - setQuery(''); - onClose(query); - setInputFocus(); - }; - - const handlePrev = () => { - onSearchPrev(query); - setInputFocus(); - }; - - const handleNext = () => { - onSearchNext(query); - setInputFocus(); - }; - - const handleReplace = () => { - onReplaceNext(query, replacement); - setInputFocus(); - }; - - const handleReplaceAll = () => { - onReplaceAll(query, replacement); - setInputFocus(); - }; - - const handleIsCaseSensitive = () => { - onConfigChange({ - caseSensitive: !isCaseSensitive, - wholeWord: isWholeWord, - }); - setIsCaseSensitive(inverse); - setInputFocus(); - }; - - const handleIsWholeWord = () => { - onConfigChange({ - caseSensitive: isCaseSensitive, - wholeWord: !isWholeWord, - }); - setIsWholeWord(inverse); - setInputFocus(); - }; - - const handleSearchKeyPress: TextInputProps['onKeyPress'] = enterKeyHandler(handleNext); - - return ( - -
- {i18n('title')} - -
- - - - - } - /> - - - - - } - /> - - {i18n('label_case-sensitive')} - - - {i18n('label_whole-word')} - -
- ); -}; - -export interface SearchPopupProps extends SearchCardProps { - open: boolean; - anchor: HTMLElement; - onClose: () => void; -} - -export const SearchPopup: React.FC = ({open, anchor, ...props}) => { - return ( - { - if (reason === 'escape-key') { - props.onClose(); - } - }} - > - - - ); -}; - -SearchPopup.displayName = 'SearchPopup'; - -interface SearchPopupWithRefProps extends Omit { - anchor: HTMLElement | null; -} - -export function renderSearchPopup({anchor, ...props}: SearchPopupWithRefProps) { - return <>{anchor && }; -} diff --git a/src/modules/search/components/SearchCardView.scss b/src/modules/search/components/SearchCardView.scss new file mode 100644 index 000000000..1b319e5dc --- /dev/null +++ b/src/modules/search/components/SearchCardView.scss @@ -0,0 +1,12 @@ +@use './constants' as const; + +.g-md-search-card { + $b: &; + + width: const.$width; + + &__row#{$b}__row { + --gc-form-row-label-width: 86px; + --gc-form-row-field-height: 36px; + } +} diff --git a/src/modules/search/components/SearchCardView.tsx b/src/modules/search/components/SearchCardView.tsx new file mode 100644 index 000000000..e08b6aa49 --- /dev/null +++ b/src/modules/search/components/SearchCardView.tsx @@ -0,0 +1,149 @@ +import {useRef} from 'react'; + +import {DelayedTextInput, FormRow} from '@gravity-ui/components'; +import {Xmark} from '@gravity-ui/icons'; +import {Button, Card, Checkbox, type DOMProps, Flex, Icon, Text, sp} from '@gravity-ui/uikit'; + +import {cn} from 'src/classname'; +import {i18n} from 'src/i18n/search'; +import {useAutoFocus} from 'src/react-utils/useAutoFocus'; + +import {SearchQA} from '../qa'; +import type {SearchCounter, SearchState} from '../types'; + +import {SearchTextInput} from './SearchTextInput'; + +import './SearchCardView.scss'; + +export type SearchCardViewProps = DOMProps & { + counter?: SearchCounter; + searchState: SearchState; + onClose: () => void; + onSearchChange: (value: string) => void; + onReplacementChange: (value: string) => void; + onFindPrevious: () => void; + onFindNext: () => void; + onReplace: () => void; + onReplaceAll: () => void; + onWholeWordChange: (value: boolean) => void; + onCaseSensitiveChange: (value: boolean) => void; +}; + +const b = cn('search-card'); + +export const SearchCardView: React.FC = ({ + style, + className, + + counter, + searchState, + onClose, + onSearchChange, + onReplacementChange, + onFindPrevious, + onFindNext, + onReplace, + onReplaceAll, + onWholeWordChange, + onCaseSensitiveChange, +}) => { + const searchInputRef = useRef(null); + + useAutoFocus(searchInputRef, [], 200); + + return ( + + + + {i18n('title')} + + + + + + + + + {i18n('label_case-sensitive')} + + + + {i18n('label_whole-word')} + + + + + + + + + + + + + ); +}; diff --git a/src/modules/search/components/SearchCompactView.scss b/src/modules/search/components/SearchCompactView.scss new file mode 100644 index 000000000..4b9367563 --- /dev/null +++ b/src/modules/search/components/SearchCompactView.scss @@ -0,0 +1,8 @@ +@use './constants' as const; + +.g-md-search-compact { + display: flex; + align-items: center; + + width: const.$width; +} diff --git a/src/modules/search/components/SearchCompactView.tsx b/src/modules/search/components/SearchCompactView.tsx new file mode 100644 index 000000000..96b20fc66 --- /dev/null +++ b/src/modules/search/components/SearchCompactView.tsx @@ -0,0 +1,114 @@ +import {useRef} from 'react'; + +import {ChevronsExpandUpRight, Xmark} from '@gravity-ui/icons'; +import { + Button, + ButtonIcon, + Card, + type DOMProps, + Icon, + type QAProps, + Tooltip, + sp, +} from '@gravity-ui/uikit'; + +import {cn} from 'src/classname'; +import {i18n} from 'src/i18n/search'; +import {useAutoFocus} from 'src/react-utils/useAutoFocus'; + +import {SearchQA} from '../qa'; +import type {SearchCounter} from '../types'; + +import {SearchTextInput} from './SearchTextInput'; + +import './SearchCompactView.scss'; + +const b = cn('search-compact'); + +export type SearchCompactProps = DOMProps & + QAProps & { + value: string; + counter?: SearchCounter; + onChange: (value: string) => void; + onFindPrevious: () => void; + onFindNext: () => void; + onExpand: () => void; + onClose: () => void; + }; + +export const SeachCompactView: React.FC = function SearchCompactView({ + value, + counter, + + onChange, + onFindNext, + onFindPrevious, + onExpand, + onClose, + + qa, + style, + className, +}) { + const inputRef = useRef(null); + + useAutoFocus(inputRef, [], 200); + + return ( + + + +
+ + {/* eslint-disable-next-line jsx-a11y/aria-role */} + + {(props, ref) => ( + + )} + + + + ); +}; diff --git a/src/modules/search/components/SearchCounter.tsx b/src/modules/search/components/SearchCounter.tsx new file mode 100644 index 000000000..e1bfaacde --- /dev/null +++ b/src/modules/search/components/SearchCounter.tsx @@ -0,0 +1,21 @@ +import {type DOMProps, Text} from '@gravity-ui/uikit'; + +import {i18n} from 'src/i18n/search'; + +import {SearchQA} from '../qa'; +import type {SearchCounter} from '../types'; + +export type SearchCounterProps = DOMProps & { + counter: SearchCounter; +}; + +export const SearchCounterText: React.FC = ({counter, style, className}) => { + return ( + + {i18n('search_counter', { + current: String(counter.current), + total: String(counter.total), + })} + + ); +}; diff --git a/src/modules/search/components/SearchPopup.scss b/src/modules/search/components/SearchPopup.scss new file mode 100644 index 000000000..c21fbbb0a --- /dev/null +++ b/src/modules/search/components/SearchPopup.scss @@ -0,0 +1,10 @@ +.g-md-search-popup { + --g-popup-border-width: 0; + --g-popup-border-radius: 16px; + border-radius: 16px; // for old uikit + + &_compact { + --g-popup-border-radius: var(--g-border-radius-l); + border-radius: var(--g-border-radius-l); // for old uikit + } +} diff --git a/src/modules/search/components/SearchPopup.tsx b/src/modules/search/components/SearchPopup.tsx new file mode 100644 index 000000000..289899a5a --- /dev/null +++ b/src/modules/search/components/SearchPopup.tsx @@ -0,0 +1,84 @@ +import {Popup} from '@gravity-ui/uikit'; + +import {cn} from 'src/classname'; + +import {type UseSearchProps, useSearch} from '../hooks/use-search'; +import {SearchQA} from '../qa'; +import type {SearchCounter} from '../types'; + +import {SearchCardView} from './SearchCardView'; +import {SeachCompactView} from './SearchCompactView'; + +import './SearchPopup.scss'; + +const b = cn('search-popup'); + +export type SearchPopupProps = UseSearchProps & { + open: boolean; + anchor: Element; + counter?: SearchCounter; + onClose: () => void; +}; + +export const SearchPopup: React.FC = ({ + open, + anchor, + counter, + onClose, + ...props +}) => { + const {isCompact, searchState, handlers} = useSearch(props); + + return ( + { + if (reason === 'escape-key') { + onClose(); + } + }} + > + {isCompact ? ( + + ) : ( + + )} + + ); +}; + +SearchPopup.displayName = 'SearchPopup'; + +interface SearchPopupWithRefProps extends Omit { + anchor: Element | null; +} + +export function renderSearchPopup({anchor, ...props}: SearchPopupWithRefProps) { + if (!anchor) return null; + + return ; +} diff --git a/src/modules/search/components/SearchTextInput.tsx b/src/modules/search/components/SearchTextInput.tsx new file mode 100644 index 000000000..20485f76a --- /dev/null +++ b/src/modules/search/components/SearchTextInput.tsx @@ -0,0 +1,76 @@ +import {useCallback} from 'react'; + +import {DelayedTextInput} from '@gravity-ui/components'; +import {ChevronDown, ChevronUp} from '@gravity-ui/icons'; +import {Button, ButtonIcon, Icon, sp} from '@gravity-ui/uikit'; + +import {i18n} from 'src/i18n/search'; + +import {SearchQA} from '../qa'; +import type {SearchCounter} from '../types'; + +import {SearchCounterText} from './SearchCounter'; + +type DelayedTextInputProps = React.ComponentProps; + +export type SearchTextInputProps = DelayedTextInputProps & { + counter?: SearchCounter; + onFindNext: () => void; + onFindPrevious: () => void; +}; + +export const SearchTextInput: React.FC = function SearchTextInput({ + counter, + onFindNext, + onFindPrevious, + ...inputProps +}) { + const handleKeyDown = useCallback( + (event) => { + if (event.key === 'Enter') { + if (event.shiftKey) onFindPrevious(); + else onFindNext(); + } + }, + [onFindNext, onFindPrevious], + ); + + return ( + + {counter && } + + + + ) + } + {...inputProps} + /> + ); +}; diff --git a/src/modules/search/components/_constants.scss b/src/modules/search/components/_constants.scss new file mode 100644 index 000000000..a3de09b53 --- /dev/null +++ b/src/modules/search/components/_constants.scss @@ -0,0 +1 @@ +$width: 428px; diff --git a/src/modules/search/hooks/use-search.ts b/src/modules/search/hooks/use-search.ts new file mode 100644 index 000000000..e555d8a18 --- /dev/null +++ b/src/modules/search/hooks/use-search.ts @@ -0,0 +1,77 @@ +import {useBooleanState} from 'src/react-utils'; + +import type {SearchState} from '../types'; + +export type UseSearchProps = { + state: SearchState; + onChange: (config: SearchState) => void; + onSearchPrev: (config: SearchState) => void; + onSearchNext: (config: SearchState) => void; + onReplaceNext: (config: SearchState) => void; + onReplaceAll: (config: SearchState) => void; +}; + +export function useSearch({ + state, + onChange, + onSearchPrev, + onSearchNext, + onReplaceNext, + onReplaceAll, +}: UseSearchProps) { + const [isCompact, , showFullForm] = useBooleanState(true); + + const handleSearchChange = (value: string) => { + onChange({...state, search: value}); + }; + + const handleReplacementChange = (value: string) => { + onChange({...state, replace: value}); + }; + + const handleFindPrevious = () => { + onSearchPrev(state); + }; + + const handleFindNext = () => { + onSearchNext(state); + }; + + const handleReplace = () => { + onReplaceNext(state); + }; + + const handleReplaceAll = () => { + onReplaceAll(state); + }; + + const handleCaseSensitiveChange = (val: boolean) => { + onChange({...state, caseSensitive: val}); + }; + + const handleWholeWordChange = (val: boolean) => { + onChange({...state, wholeWord: val}); + }; + + return { + isCompact, + + searchState: state, + + handlers: { + onExpand: showFullForm, + + onSearchChange: handleSearchChange, + onReplaceChange: handleReplacementChange, + + onWholeWordChange: handleWholeWordChange, + onCaseSensitiveChange: handleCaseSensitiveChange, + + onFindPrevious: handleFindPrevious, + onFindNext: handleFindNext, + + onReplace: handleReplace, + onReplaceAll: handleReplaceAll, + }, + }; +} diff --git a/src/modules/search/index.ts b/src/modules/search/index.ts new file mode 100644 index 000000000..90e0c5fe3 --- /dev/null +++ b/src/modules/search/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './components/SearchPopup'; +export {SearchQA} from './qa'; diff --git a/src/modules/search/qa.ts b/src/modules/search/qa.ts new file mode 100644 index 000000000..13892511f --- /dev/null +++ b/src/modules/search/qa.ts @@ -0,0 +1,14 @@ +export const SearchQA = { + Panel: 'g-md-search-panel', + Counter: 'g-md-search-counter', + NextBtn: 'g-md-search-next-btn', + PrevBtn: 'g-md-search-prev-btn', + FindInput: 'g-md-search-find-input', + ReplaceInput: 'g-md-search-replace-input', + ReplaceBtn: 'g-md-search-replace-btn', + ReplaceAllBtn: 'g-md-search-replace-all-btn', + ExpandBtn: 'g-md-search-expand-btn', + CaseSensitiveCheck: 'g-md-search-case-sensitive-check', + WholeWordCheck: 'g-md-search-whole-word-check', + CloseBtn: 'g-md-search-close-btn', +} as const; diff --git a/src/modules/search/types.ts b/src/modules/search/types.ts new file mode 100644 index 000000000..8bc9201db --- /dev/null +++ b/src/modules/search/types.ts @@ -0,0 +1,11 @@ +export type SearchState = { + search: string; + replace: string; + caseSensitive: boolean; + wholeWord: boolean; +}; + +export type SearchCounter = { + current: number; + total: number; +}; diff --git a/src/react-utils/useAutoFocus.ts b/src/react-utils/useAutoFocus.ts index 13bdd175d..487e49136 100644 --- a/src/react-utils/useAutoFocus.ts +++ b/src/react-utils/useAutoFocus.ts @@ -1,16 +1,19 @@ import {type RefObject, useEffect} from 'react'; -export const useAutoFocus = (nodeRef: RefObject, dependencies: unknown[] = []) => { +export function useAutoFocus( + ref: RefObject, + dependencies: unknown[] = [], + timeout = 0, +) { useEffect(() => { - const {current: anchor} = nodeRef; - const timeout = setTimeout(() => { - anchor?.focus(); - }); + const timeoutId = setTimeout(() => { + ref.current?.focus(); + }, timeout); return () => { - clearTimeout(timeout); + clearTimeout(timeoutId); }; // https://github.com/facebook/react/issues/23392#issuecomment-1055610198 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeRef.current, ...dependencies]); -}; + }, dependencies); +} diff --git a/tests/playwright/core/editor.ts b/tests/playwright/core/editor.ts index c14393cf0..148bbec61 100644 --- a/tests/playwright/core/editor.ts +++ b/tests/playwright/core/editor.ts @@ -1,6 +1,7 @@ import type {Expect, Locator, Page} from '@playwright/test'; import type {DataTransferType, MarkdownEditorMode} from 'src'; +import {SearchQA} from 'src/modules/search'; type YfmTableCellMenuType = 'row' | 'column'; type YfmTableActionKind = @@ -198,6 +199,84 @@ class YfmTable { } } +class SearchPanel { + readonly panelLocator; + + readonly closeButtonLocator; + readonly expandButtonLocator; + + readonly counterLocator; + readonly nextButtonLocator; + readonly prevButtonLocator; + + readonly searchInputLocator; + readonly replaceInputLocator; + + readonly caseSensitiveCheckboxLocator; + readonly wholeWordCheckboxLocator; + + readonly replaceButtonLocator; + readonly replaceAllButtonLocator; + + constructor(page: Page) { + this.panelLocator = page.getByTestId(SearchQA.Panel); + + this.closeButtonLocator = this.panelLocator.getByTestId(SearchQA.CloseBtn); + this.expandButtonLocator = this.panelLocator.getByTestId(SearchQA.ExpandBtn); + + this.counterLocator = this.panelLocator.getByTestId(SearchQA.Counter); + this.nextButtonLocator = this.panelLocator.getByTestId(SearchQA.NextBtn); + this.prevButtonLocator = this.panelLocator.getByTestId(SearchQA.PrevBtn); + + this.searchInputLocator = this.panelLocator.getByTestId(SearchQA.FindInput); + this.replaceInputLocator = this.panelLocator.getByTestId(SearchQA.ReplaceInput); + + this.caseSensitiveCheckboxLocator = this.panelLocator.getByTestId( + SearchQA.CaseSensitiveCheck, + ); + this.wholeWordCheckboxLocator = this.panelLocator.getByTestId(SearchQA.WholeWordCheck); + + this.replaceButtonLocator = this.panelLocator.getByTestId(SearchQA.ReplaceBtn); + this.replaceAllButtonLocator = this.panelLocator.getByTestId(SearchQA.ReplaceAllBtn); + } + + waitForVisible() { + return this.panelLocator.waitFor({state: 'visible'}); + } + + waitForHidden() { + return this.panelLocator.waitFor({state: 'hidden'}); + } + + close() { + return this.closeButtonLocator.click(); + } + + expand() { + return this.expandButtonLocator.click(); + } + + getCounterText() { + return this.counterLocator.textContent(); + } + + fillFindText(text: string) { + return this.searchInputLocator.locator('input').fill(text); + } + + fillReplaceText(text: string) { + return this.replaceInputLocator.locator('input').fill(text); + } + + replace() { + return this.replaceButtonLocator.click(); + } + + replaceAll() { + return this.replaceAllButtonLocator.click(); + } +} + class MarkdownEditorLocators { readonly component; readonly contenteditable; @@ -248,6 +327,7 @@ export class MarkdownEditorPage { readonly yfmNote; readonly image; readonly link; + readonly searchPanel; protected readonly page: Page; protected readonly expect: Expect; @@ -261,6 +341,7 @@ export class MarkdownEditorPage { this.yfmNote = new YfmNote(page); this.image = new Image(page); this.link = new Link(page, expect); + this.searchPanel = new SearchPanel(page); } /** @@ -698,4 +779,10 @@ export class MarkdownEditorPage { async waitForCMAutocomplete() { await this.locators.cmAutocomplete.waitFor({state: 'visible'}); } + + async openSearchPanel() { + await this.focus(); + await this.locators.contenteditable.press('Meta+F'); + await this.searchPanel.waitForVisible(); + } } diff --git a/tests/visual-tests/playground/SearchPanel.visual.test.tsx b/tests/visual-tests/playground/SearchPanel.visual.test.tsx new file mode 100644 index 000000000..3120a6c5e --- /dev/null +++ b/tests/visual-tests/playground/SearchPanel.visual.test.tsx @@ -0,0 +1,204 @@ +import dd from 'ts-dedent'; + +import {expect, test} from 'playwright/core'; + +import {Playground} from './Playground.helpers'; + +test.describe('SearchPanel', () => { + test.beforeEach(async ({mount}) => { + const markup = dd` + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean massa purus, commodo et leo vel, pretium facilisis elit. + + Vivamus a risus sed orci interdum volutpat. Vivamus aliquet euismod laoreet. Donec nec erat non sapien luctus scelerisque. + + Duis fringilla eros sem, id luctus urna maximus sit amet. Aenean vitae massa ipsum. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. + + Phasellus in turpis vitae orci suscipit ultrices. Ut interdum urna a quam aliquet, sed ultricies diam egestas. + `; + + await mount(); + }); + + test.describe('mode:wysiwyg', () => { + test.beforeEach(async ({editor}) => { + await editor.switchMode('wysiwyg'); + }); + + test('should open compact search panel', async ({editor, platform, expectScreenshot}) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + + await expectScreenshot({ + component: editor.searchPanel.panelLocator, + }); + }); + + test('should open full search panel', async ({ + editor, + platform, + expectScreenshot, + wait, + }) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.expand(); + await wait.hidden(editor.searchPanel.expandButtonLocator); + + await expectScreenshot({ + component: editor.searchPanel.panelLocator, + }); + }); + + test('should close compact search panel', async ({editor, platform}) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.close(); + await editor.searchPanel.waitForHidden(); + }); + + test('should close full search panel', async ({editor, platform, wait}) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.expand(); + await wait.hidden(editor.searchPanel.expandButtonLocator); + await editor.searchPanel.close(); + await editor.searchPanel.waitForHidden(); + }); + + test('should activate replace buttons', async ({ + editor, + platform, + wait, + expectScreenshot, + }) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.expand(); + await editor.searchPanel.fillFindText('us'); + await wait.timeout(200); + + await expectScreenshot({ + component: editor.searchPanel.panelLocator, + }); + }); + + test('should update counter after one replacement', async ({editor, platform, wait}) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.expand(); + await editor.searchPanel.fillFindText('us'); + await wait.timeout(100); + + expect(await editor.searchPanel.getCounterText()).toBe('1 of 11'); + + await editor.searchPanel.fillReplaceText('11'); + await editor.searchPanel.replace(); + await wait.timeout(100); + + expect(await editor.searchPanel.getCounterText()).toBe('1 of 10'); + }); + + test('should update counter after all replacement', async ({editor, platform, wait}) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.expand(); + await editor.searchPanel.fillFindText('us'); + await wait.timeout(100); + + expect(await editor.searchPanel.getCounterText()).toBe('1 of 11'); + + await editor.searchPanel.fillReplaceText('22'); + await editor.searchPanel.replaceAll(); + await wait.timeout(100); + + expect(await editor.searchPanel.getCounterText()).toBe('0 of 0'); + }); + + test('should update counter after switching case sensitive flag', async ({ + editor, + platform, + wait, + }) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.expand(); + await editor.searchPanel.fillFindText('Ut'); + await wait.timeout(100); + + expect(await editor.searchPanel.getCounterText()).toBe('1 of 2'); + + await editor.searchPanel.caseSensitiveCheckboxLocator.click(); + await wait.timeout(100); + + expect(await editor.searchPanel.getCounterText()).toBe('1 of 1'); + }); + + test('should update counter after switching whole word flag', async ({ + editor, + platform, + wait, + }) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.expand(); + await editor.searchPanel.fillFindText('Ut'); + await wait.timeout(100); + + expect(await editor.searchPanel.getCounterText()).toBe('1 of 2'); + + await editor.searchPanel.wholeWordCheckboxLocator.click(); + await wait.timeout(100); + + expect(await editor.searchPanel.getCounterText()).toBe('1 of 1'); + }); + }); + + test.describe('mode:markup', () => { + test.beforeEach(async ({editor}) => { + await editor.switchMode('markup'); + }); + + test('should open compact search panel', async ({editor, platform, wait}) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await wait.visible(editor.searchPanel.expandButtonLocator); + }); + + test('should open full search panel', async ({editor, platform, wait}) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.expand(); + await wait.hidden(editor.searchPanel.expandButtonLocator); + await wait.visible(editor.searchPanel.replaceAllButtonLocator); + }); + + test('should close compact search panel', async ({editor, platform}) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.close(); + await editor.searchPanel.waitForHidden(); + }); + + test('should close full search panel', async ({editor, platform, wait}) => { + test.skip(platform === 'linux', 'key combo fails in docker container'); + + await editor.openSearchPanel(); + await editor.searchPanel.expand(); + await wait.hidden(editor.searchPanel.expandButtonLocator); + await editor.searchPanel.close(); + await editor.searchPanel.waitForHidden(); + }); + }); +});