Skip to content

Commit 0f105b2

Browse files
committed
feat(wysiwyg): add search and replace functionality to wysiwyg mode
1 parent e873006 commit 0f105b2

27 files changed

+572
-94
lines changed

package-lock.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@
208208
"prosemirror-keymap": "^1.2.2",
209209
"prosemirror-model": "^1.24.1",
210210
"prosemirror-schema-list": "^1.5.0",
211+
"prosemirror-search": "^1.1.0",
211212
"prosemirror-state": "^1.4.3",
212213
"prosemirror-test-builder": "^1.1.1",
213214
"prosemirror-transform": "^1.10.2",

src/bundle/MarkdownEditorView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ MarkdownEditorView.displayName = 'MarkdownEditorView';
420420
interface MarkupSearchAnchorProps extends Pick<EditorSettingsProps, 'mode'> {}
421421

422422
const MarkupSearchAnchor: React.FC<MarkupSearchAnchorProps> = ({mode}) => (
423-
<>{mode === 'markup' && <div className="g-md-search-anchor"></div>}</>
423+
<div className={`g-md-search-${mode}-anchor`}></div>
424424
);
425425

426426
function Settings(props: EditorSettingsProps & {stickyToolbar: boolean}) {

src/bundle/wysiwyg-preset.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export const BundlePreset: ExtensionAuto<BundlePresetOptions> = (builder, opts)
7373
},
7474
},
7575
cursor: {dropOptions: dropCursor},
76+
search: {anchorSelector: '.g-md-search-wysiwyg-anchor'},
7677
clipboard: {pasteFileHandler: opts.fileUploadHandler, ...opts.clipboard},
7778
selectionContext: {config: wSelectionMenuConfigByPreset.zero, ...opts.selectionContext},
7879
commandMenu: {actions: wCommandMenuConfigByPreset.zero, ...opts.commandMenu},
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {search} from 'prosemirror-search';
2+
3+
import type {ExtensionAuto} from '#core';
4+
5+
import {type SearchViewPluginParams, searchViewPlugin} from './SearchViewPlugin';
6+
7+
export type SearchOptions = Pick<SearchViewPluginParams, 'anchorSelector'>;
8+
9+
export const Search: ExtensionAuto<SearchOptions> = (builder, opts) => {
10+
builder.addPlugin(() => [search(), searchViewPlugin(opts)]);
11+
};
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import {SearchQuery, getSearchState, setSearchState} from 'prosemirror-search';
2+
3+
import {type Command, Plugin, type PluginView} from '#pm/state';
4+
import type {EditorView} from '#pm/view';
5+
import {debounce} from 'src/lodash';
6+
import {type SearchCounter, type SearchPopupProps, renderSearchPopup} from 'src/modules/search';
7+
8+
import {getReactRendererFromState} from '../ReactRenderer';
9+
10+
import {closeSearch, findNext, findPrev, replaceAll, replaceNext} from './commands';
11+
import {pluginKey} from './const';
12+
import {searchKeyHandler} from './key-handler';
13+
import type {SearchViewState} from './types';
14+
import {startTracking} from './utils/connect-tracker';
15+
import {FocusManager} from './utils/focus-manager';
16+
import {getCounter} from './utils/search-counter';
17+
18+
import './search-plugin.scss';
19+
20+
type SearchQueryConfig = ConstructorParameters<typeof SearchQuery>[0];
21+
22+
export interface SearchViewPluginParams {
23+
anchorSelector: string;
24+
}
25+
26+
export const searchViewPlugin = (params: SearchViewPluginParams) => {
27+
return new Plugin<SearchViewState>({
28+
key: pluginKey,
29+
props: {
30+
handleKeyDown: searchKeyHandler,
31+
},
32+
state: {
33+
init: () => ({open: false}),
34+
apply(tr, value, _oldState, _newState) {
35+
const newValue = tr.getMeta(pluginKey) as SearchViewState | undefined;
36+
if (typeof newValue === 'object') return newValue;
37+
return value;
38+
},
39+
},
40+
view(view) {
41+
return new SeachPluginView(view, params);
42+
},
43+
});
44+
};
45+
46+
class SeachPluginView implements PluginView {
47+
private readonly _view: EditorView;
48+
private readonly _renderer;
49+
private readonly _focusManager: FocusManager;
50+
private readonly _viewDomTrackerDispose;
51+
52+
private _counter: SearchCounter;
53+
private _isDomConnected: boolean;
54+
private _viewState: SearchViewState | undefined;
55+
private _searchState: ReturnType<typeof getSearchState>;
56+
57+
private readonly _onSearchChangeDebounced;
58+
private readonly _onReplacementChangeDebounced;
59+
60+
constructor(view: EditorView, params: SearchViewPluginParams) {
61+
this._view = view;
62+
this._viewState = pluginKey.getState(view.state);
63+
this._searchState = getSearchState(view.state);
64+
this._counter = getCounter(view.state);
65+
this._isDomConnected = view.dom.ownerDocument.contains(view.dom);
66+
67+
this._renderer = this._createRenderer(params);
68+
this._focusManager = new FocusManager(view.dom.ownerDocument);
69+
70+
// uses MutationObserver to detect when view.dom is disconnected from the DOM tree
71+
// TODO: replace with eventBus (subscribe to change-editor-mode event) to track when to hide the search bar
72+
// see https://github.com/gravity-ui/markdown-editor/issues/884
73+
this._viewDomTrackerDispose = startTracking(view.dom, {
74+
onConnect: this._onEditorViewDomConnected,
75+
onDisconnect: this._onEditorViewDomDisconnected,
76+
});
77+
78+
this._onSearchChangeDebounced = debounce(this._handleSearchChange.bind(this));
79+
this._onReplacementChangeDebounced = debounce(this._handleReplacementChange.bind(this));
80+
}
81+
82+
update() {
83+
const counter = getCounter(this._view.state);
84+
const newViewState = pluginKey.getState(this._view.state);
85+
if (
86+
newViewState !== this._viewState ||
87+
counter.total !== this._counter.total ||
88+
counter.current !== this._counter.current
89+
) {
90+
this._counter = counter;
91+
this._viewState = newViewState;
92+
this._searchState = getSearchState(this._view.state);
93+
this._renderer.rerender();
94+
}
95+
}
96+
97+
destroy() {
98+
this._onSearchChangeDebounced.cancel();
99+
this._onReplacementChangeDebounced.cancel();
100+
this._renderer.remove();
101+
this._viewDomTrackerDispose();
102+
}
103+
104+
private _onEditorViewDomConnected = () => {
105+
this._isDomConnected = true;
106+
this._renderer.rerender();
107+
};
108+
109+
private _onEditorViewDomDisconnected = () => {
110+
this._isDomConnected = false;
111+
this._onClose();
112+
};
113+
114+
private _createRenderer(params: Pick<SearchViewPluginParams, 'anchorSelector'>) {
115+
return getReactRendererFromState(this._view.state).createItem('search-view', () => {
116+
const {
117+
_viewState: viewState,
118+
_searchState: searchState,
119+
_isDomConnected: domConnected,
120+
} = this;
121+
122+
if (!domConnected || !viewState?.open || !searchState) return null;
123+
124+
const anchor = this._view.dom.ownerDocument.querySelector(params.anchorSelector);
125+
126+
return renderSearchPopup({
127+
anchor,
128+
open: viewState.open,
129+
counter: this._counter,
130+
initial: searchState.query,
131+
onClose: this._onClose,
132+
onSearchPrev: this._onSearchPrev,
133+
onSearchNext: this._onSearchNext,
134+
onReplaceNext: this._onReplaceNext,
135+
onReplaceAll: this._onReplaceAll,
136+
onConfigChange: this._onConfigChange,
137+
onQueryChange: this._onSearchChangeDebounced,
138+
onReplacementChange: this._onReplacementChangeDebounced,
139+
});
140+
});
141+
}
142+
143+
private _handleSearchChange(value: string) {
144+
this._updateSearchState({search: value});
145+
}
146+
147+
private _handleReplacementChange(value: string) {
148+
this._updateSearchState({replace: value});
149+
}
150+
151+
private _updateSearchState(config: Partial<SearchQueryConfig>) {
152+
const {state, dispatch} = this._view;
153+
let prevConfig: SearchQueryConfig = {search: ''};
154+
155+
const prevSearchState = getSearchState(state);
156+
if (prevSearchState) {
157+
prevConfig = {
158+
search: prevSearchState.query.search,
159+
caseSensitive: prevSearchState.query.caseSensitive,
160+
wholeWord: prevSearchState.query.wholeWord,
161+
regexp: prevSearchState.query.regexp,
162+
replace: prevSearchState.query.replace,
163+
};
164+
}
165+
166+
dispatch(
167+
setSearchState(
168+
state.tr,
169+
new SearchQuery({
170+
...prevConfig,
171+
...config,
172+
}),
173+
),
174+
);
175+
}
176+
177+
private _onClose = () => {
178+
this._onSearchChangeDebounced.cancel();
179+
this._onReplacementChangeDebounced.cancel();
180+
closeSearch(this._view.state, this._view.dispatch);
181+
this._view.focus();
182+
};
183+
184+
private _onSearchPrev = () => {
185+
this._onSearchChangeDebounced.flush();
186+
this._preserveFocus(findPrev);
187+
};
188+
189+
private _onSearchNext = () => {
190+
this._onSearchChangeDebounced.flush();
191+
this._preserveFocus(findNext);
192+
};
193+
194+
private _onReplaceNext = () => {
195+
this._onSearchChangeDebounced.flush();
196+
this._onReplacementChangeDebounced.flush();
197+
this._preserveFocus(replaceNext);
198+
};
199+
200+
private _onReplaceAll = () => {
201+
this._onSearchChangeDebounced.flush();
202+
this._onReplacementChangeDebounced.flush();
203+
this._preserveFocus(replaceAll);
204+
};
205+
206+
private _onConfigChange: SearchPopupProps['onConfigChange'] = (config) => {
207+
this._updateSearchState({
208+
wholeWord: config.wholeWord,
209+
caseSensitive: config.caseSensitive,
210+
});
211+
};
212+
213+
private _preserveFocus(command: Command) {
214+
this._focusManager.storeFocus();
215+
this._view.focus();
216+
command(this._view.state, this._view.dispatch, this._view);
217+
this._focusManager.restoreFocus({preventScroll: true});
218+
}
219+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {
2+
SearchQuery,
3+
findNext as findNextSearch,
4+
findPrev as findPrevSearch,
5+
getSearchState,
6+
replaceAll as replaceAllSearch,
7+
replaceNext as replaceNextSearch,
8+
setSearchState,
9+
} from 'prosemirror-search';
10+
11+
import type {Command} from '#pm/state';
12+
13+
import {hideSelectionMenu} from '../SelectionContext';
14+
15+
import {pluginKey} from './const';
16+
import type {SearchViewState} from './types';
17+
import {wrapCommand} from './utils/wrap-command';
18+
19+
export const findNext = wrapCommand(findNextSearch, hideSelectionMenu);
20+
export const findPrev = wrapCommand(findPrevSearch, hideSelectionMenu);
21+
export const replaceNext = wrapCommand(replaceNextSearch, hideSelectionMenu);
22+
export const replaceAll = wrapCommand(replaceAllSearch, hideSelectionMenu);
23+
24+
export const openSearch: Command = (state, dispatch) => {
25+
if (dispatch) {
26+
const searchState = getSearchState(state);
27+
const search = state.doc.textBetween(state.selection.from, state.selection.to, ' ');
28+
const meta: SearchViewState = {open: true};
29+
dispatch(
30+
setSearchState(
31+
hideSelectionMenu(state.tr.setMeta(pluginKey, meta)),
32+
new SearchQuery({
33+
...(searchState
34+
? {
35+
regexp: searchState.query.regexp,
36+
replace: searchState.query.replace,
37+
literal: searchState.query.literal,
38+
wholeWord: searchState.query.wholeWord,
39+
caseSensitive: searchState.query.caseSensitive,
40+
filter: searchState.query.filter || undefined,
41+
}
42+
: undefined),
43+
search,
44+
}),
45+
),
46+
);
47+
}
48+
return true;
49+
};
50+
51+
export const closeSearch: Command = (state, dispatch) => {
52+
if (dispatch) {
53+
const meta: SearchViewState = {open: false};
54+
dispatch(setSearchState(state.tr.setMeta(pluginKey, meta), new SearchQuery({search: ''})));
55+
}
56+
return true;
57+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {PluginKey} from '#pm/state';
2+
3+
import type {SearchViewState} from './types';
4+
5+
export const SearchClassName = {
6+
Match: 'ProseMirror-search-match',
7+
ActiveMatch: 'ProseMirror-active-search-match',
8+
} as const;
9+
10+
export const pluginKey = new PluginKey<SearchViewState>('search-view');
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './Search';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {keydownHandler} from '#pm/keymap';
2+
3+
import {closeSearch, findNext, findPrev, openSearch} from './commands';
4+
5+
export const searchKeyHandler = keydownHandler({
6+
'Mod-f': openSearch,
7+
Escape: closeSearch,
8+
F3: findNext,
9+
'Shift-F3': findPrev,
10+
'Mod-g': findNext,
11+
'Shift-Mod-g': findPrev,
12+
});

0 commit comments

Comments
 (0)