Skip to content

Commit 0285ba2

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

27 files changed

+564
-92
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: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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/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+
this._viewDomTrackerDispose = startTracking(view.dom, {
70+
onConnect: this._onEditorViewDomConnected,
71+
onDisconnect: this._onEditorViewDomDisconnected,
72+
});
73+
74+
this._onSearchChangeDebounced = debounce(this._handleSearchChange.bind(this));
75+
this._onReplacementChangeDebounced = debounce(this._handleReplacementChange.bind(this));
76+
}
77+
78+
update() {
79+
const counter = getCounter(this._view.state);
80+
const newViewState = pluginKey.getState(this._view.state);
81+
if (
82+
newViewState !== this._viewState ||
83+
counter.total !== this._counter.total ||
84+
counter.current !== this._counter.current
85+
) {
86+
this._counter = counter;
87+
this._viewState = newViewState;
88+
this._searchState = getSearchState(this._view.state);
89+
this._renderer.rerender();
90+
}
91+
}
92+
93+
destroy() {
94+
this._onSearchChangeDebounced.cancel();
95+
this._onReplacementChangeDebounced.cancel();
96+
this._renderer.remove();
97+
this._viewDomTrackerDispose();
98+
}
99+
100+
private _onEditorViewDomConnected = () => {
101+
this._isDomConnected = true;
102+
this._renderer.rerender();
103+
};
104+
105+
private _onEditorViewDomDisconnected = () => {
106+
this._isDomConnected = false;
107+
this._onClose();
108+
};
109+
110+
private _createRenderer(params: Pick<SearchViewPluginParams, 'anchorSelector'>) {
111+
return getReactRendererFromState(this._view.state).createItem('search-view', () => {
112+
const {
113+
_viewState: viewState,
114+
_searchState: searchState,
115+
_isDomConnected: domConnected,
116+
} = this;
117+
118+
if (!domConnected || !viewState?.open || !searchState) return null;
119+
120+
const anchor = this._view.dom.ownerDocument.querySelector(params.anchorSelector);
121+
122+
return renderSearchPopup({
123+
anchor,
124+
open: viewState.open,
125+
counter: this._counter,
126+
initial: searchState.query,
127+
onClose: this._onClose,
128+
onSearchPrev: this._onSearchPrev,
129+
onSearchNext: this._onSearchNext,
130+
onReplaceNext: this._onReplaceNext,
131+
onReplaceAll: this._onReplaceAll,
132+
onConfigChange: this._onConfigChange,
133+
onQueryChange: this._onSearchChangeDebounced,
134+
onReplacementChange: this._onReplacementChangeDebounced,
135+
});
136+
});
137+
}
138+
139+
private _handleSearchChange(value: string) {
140+
this._updateSearchState({search: value});
141+
}
142+
143+
private _handleReplacementChange(value: string) {
144+
this._updateSearchState({replace: value});
145+
}
146+
147+
private _updateSearchState(config: Partial<SearchQueryConfig>) {
148+
const {state, dispatch} = this._view;
149+
let prevConfig: SearchQueryConfig = {search: ''};
150+
151+
const prevSearchState = getSearchState(state);
152+
if (prevSearchState) {
153+
prevConfig = {
154+
search: prevSearchState.query.search,
155+
caseSensitive: prevSearchState.query.caseSensitive,
156+
wholeWord: prevSearchState.query.wholeWord,
157+
regexp: prevSearchState.query.regexp,
158+
replace: prevSearchState.query.replace,
159+
};
160+
}
161+
162+
dispatch(
163+
setSearchState(
164+
state.tr,
165+
new SearchQuery({
166+
...prevConfig,
167+
...config,
168+
}),
169+
),
170+
);
171+
}
172+
173+
private _onClose = () => {
174+
this._onSearchChangeDebounced.cancel();
175+
this._onReplacementChangeDebounced.cancel();
176+
closeSearch(this._view.state, this._view.dispatch);
177+
this._view.focus();
178+
};
179+
180+
private _onSearchPrev = () => {
181+
this._onSearchChangeDebounced.flush();
182+
this._preserveFocus(findPrev);
183+
};
184+
185+
private _onSearchNext = () => {
186+
this._onSearchChangeDebounced.flush();
187+
this._preserveFocus(findNext);
188+
};
189+
190+
private _onReplaceNext = () => {
191+
this._onSearchChangeDebounced.flush();
192+
this._onReplacementChangeDebounced.flush();
193+
this._preserveFocus(replaceNext);
194+
};
195+
196+
private _onReplaceAll = () => {
197+
this._onSearchChangeDebounced.flush();
198+
this._onReplacementChangeDebounced.flush();
199+
this._preserveFocus(replaceAll);
200+
};
201+
202+
private _onConfigChange: SearchPopupProps['onConfigChange'] = (config) => {
203+
this._updateSearchState({
204+
wholeWord: config.wholeWord,
205+
caseSensitive: config.caseSensitive,
206+
});
207+
};
208+
209+
private _preserveFocus(command: Command) {
210+
this._focusManager.storeFocus();
211+
this._view.focus();
212+
command(this._view.state, this._view.dispatch, this._view);
213+
this._focusManager.restoreFocus({preventScroll: true});
214+
}
215+
}
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)