Skip to content

Commit 5c57344

Browse files
committed
feat(wysiwyg): add search and replace functionality to wysiwyg mode
1 parent 1d0292d commit 5c57344

File tree

19 files changed

+427
-3
lines changed

19 files changed

+427
-3
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/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ export type MarkdownEditorWysiwygConfig = {
190190
* Note: The use of the markdown-it-attrs plugin will be removed in the next major version.
191191
*/
192192
disableMarkdownAttrs?: boolean;
193+
/**
194+
* Show search panel in the editor.
195+
* @default true
196+
*/
197+
searchPanel?: boolean;
193198
};
194199

195200
export type MarkdownEditorOptions = {

src/bundle/useMarkdownEditor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export function useMarkdownEditor(
6767
experimental.needToSetDimensionsForUploadedImages,
6868
enableNewImageSizeCalculation: experimental.enableNewImageSizeCalculation,
6969
mobile,
70+
searchPanel: wysiwygConfig.searchPanel ?? true,
7071
});
7172
{
7273
const extraExtensions = wysiwygConfig.extensions;

src/bundle/wysiwyg-preset.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type BundlePresetOptions = ExtensionsOptions &
4343
// MAJOR: remove markdown-it-attrs
4444
disableMdAttrs?: boolean;
4545
mobile?: boolean;
46+
searchPanel: boolean;
4647
};
4748

4849
declare global {
@@ -73,6 +74,7 @@ export const BundlePreset: ExtensionAuto<BundlePresetOptions> = (builder, opts)
7374
},
7475
},
7576
cursor: {dropOptions: dropCursor},
77+
search: opts.searchPanel ? {anchorSelector: '.g-md-search-wysiwyg-anchor'} : undefined,
7678
clipboard: {pasteFileHandler: opts.fileUploadHandler, ...opts.clipboard},
7779
selectionContext: {config: wSelectionMenuConfigByPreset.zero, ...opts.selectionContext},
7880
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: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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 {type SearchCounter, type SearchState, renderSearchPopup} from 'src/modules/search';
6+
7+
import {getReactRendererFromState} from '../ReactRenderer';
8+
9+
import {closeSearch, findNext, findPrev, replaceAll, replaceNext} from './commands';
10+
import {pluginKey} from './const';
11+
import {searchKeyHandler} from './key-handler';
12+
import type {SearchViewState} from './types';
13+
import {startTracking} from './utils/connect-tracker';
14+
import {FocusManager} from './utils/focus-manager';
15+
import {getCounter} from './utils/search-counter';
16+
17+
import './search-plugin.scss';
18+
19+
export interface SearchViewPluginParams {
20+
anchorSelector: string;
21+
}
22+
23+
export const searchViewPlugin = (params: SearchViewPluginParams) => {
24+
return new Plugin<SearchViewState>({
25+
key: pluginKey,
26+
props: {
27+
handleKeyDown: searchKeyHandler,
28+
},
29+
state: {
30+
init: () => ({open: false}),
31+
apply(tr, value, _oldState, _newState) {
32+
const newValue = tr.getMeta(pluginKey) as SearchViewState | undefined;
33+
if (typeof newValue === 'object') return newValue;
34+
return value;
35+
},
36+
},
37+
view(view) {
38+
return new SeachPluginView(view, params);
39+
},
40+
});
41+
};
42+
43+
class SeachPluginView implements PluginView {
44+
private readonly _view: EditorView;
45+
private readonly _renderer;
46+
private readonly _focusManager: FocusManager;
47+
private readonly _viewDomTrackerDispose;
48+
49+
private _counter: SearchCounter;
50+
private _isDomConnected: boolean;
51+
private _viewState: SearchViewState | undefined;
52+
private _searchState: SearchQuery | undefined;
53+
54+
constructor(view: EditorView, params: SearchViewPluginParams) {
55+
this._view = view;
56+
this._viewState = pluginKey.getState(view.state);
57+
this._searchState = getSearchState(view.state)?.query;
58+
this._counter = getCounter(view.state);
59+
this._isDomConnected = view.dom.ownerDocument.contains(view.dom);
60+
61+
this._renderer = this._createRenderer(params);
62+
this._focusManager = new FocusManager(view.dom.ownerDocument);
63+
64+
// uses MutationObserver to detect when view.dom is disconnected from the DOM tree
65+
// TODO: replace with eventBus (subscribe to change-editor-mode event) to track when to hide the search bar
66+
// see https://github.com/gravity-ui/markdown-editor/issues/884
67+
this._viewDomTrackerDispose = startTracking(view.dom, {
68+
onConnect: this._onEditorViewDomConnected,
69+
onDisconnect: this._onEditorViewDomDisconnected,
70+
});
71+
}
72+
73+
update() {
74+
const newCounter = getCounter(this._view.state);
75+
const newViewState = pluginKey.getState(this._view.state);
76+
const newSearchState = getSearchState(this._view.state)?.query;
77+
78+
if (
79+
newViewState !== this._viewState ||
80+
newSearchState !== this._searchState ||
81+
newCounter.total !== this._counter.total ||
82+
newCounter.current !== this._counter.current
83+
) {
84+
if (
85+
newSearchState !== this._searchState &&
86+
newCounter.current === 0 &&
87+
newCounter.total > 0
88+
) {
89+
// after changing the search query and if no match is selected, move the selection to the next match
90+
(window.requestAnimationFrame || window.setTimeout)(() => {
91+
this._preserveFocus(findNext);
92+
});
93+
}
94+
95+
this._counter = newCounter;
96+
this._viewState = newViewState;
97+
this._searchState = newSearchState;
98+
this._renderer.rerender();
99+
}
100+
}
101+
102+
destroy() {
103+
this._renderer.remove();
104+
this._viewDomTrackerDispose();
105+
}
106+
107+
private _onEditorViewDomConnected = () => {
108+
this._isDomConnected = true;
109+
this._renderer.rerender();
110+
};
111+
112+
private _onEditorViewDomDisconnected = () => {
113+
this._isDomConnected = false;
114+
this._onClose();
115+
};
116+
117+
private _createRenderer(params: Pick<SearchViewPluginParams, 'anchorSelector'>) {
118+
return getReactRendererFromState(this._view.state).createItem('search-view', () => {
119+
const {
120+
_viewState: viewState,
121+
_searchState: searchState,
122+
_isDomConnected: domConnected,
123+
} = this;
124+
125+
if (!domConnected || !viewState?.open || !searchState) return null;
126+
127+
const anchor = this._view.dom.ownerDocument.querySelector(params.anchorSelector);
128+
129+
return renderSearchPopup({
130+
anchor,
131+
open: viewState.open,
132+
counter: this._counter,
133+
state: searchState,
134+
onClose: this._onClose,
135+
onChange: this._onChange,
136+
onSearchPrev: this._onSearchPrev,
137+
onSearchNext: this._onSearchNext,
138+
onReplaceNext: this._onReplaceNext,
139+
onReplaceAll: this._onReplaceAll,
140+
});
141+
});
142+
}
143+
144+
private _onChange = (config: SearchState) => {
145+
const {state, dispatch} = this._view;
146+
147+
dispatch(
148+
setSearchState(
149+
state.tr,
150+
new SearchQuery({
151+
search: config.search,
152+
replace: config.replace,
153+
caseSensitive: config.caseSensitive,
154+
wholeWord: config.wholeWord,
155+
}),
156+
),
157+
);
158+
};
159+
160+
private _onClose = () => {
161+
closeSearch(this._view.state, this._view.dispatch);
162+
this._view.focus();
163+
};
164+
165+
private _onSearchPrev = () => {
166+
this._preserveFocus(findPrev);
167+
};
168+
169+
private _onSearchNext = () => {
170+
this._preserveFocus(findNext);
171+
};
172+
173+
private _onReplaceNext = () => {
174+
this._preserveFocus(replaceNext);
175+
};
176+
177+
private _onReplaceAll = () => {
178+
this._preserveFocus(replaceAll);
179+
};
180+
181+
private _preserveFocus(command: Command) {
182+
this._focusManager.storeFocus();
183+
this._view.focus();
184+
command(this._view.state, this._view.dispatch, this._view);
185+
this._focusManager.restoreFocus({preventScroll: true});
186+
}
187+
}
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';

0 commit comments

Comments
 (0)