Skip to content

Commit feb1d11

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

File tree

17 files changed

+388
-87
lines changed

17 files changed

+388
-87
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: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import {
2+
SearchQuery,
3+
findNext,
4+
findPrev,
5+
getSearchState,
6+
replaceAll,
7+
replaceNext,
8+
setSearchState,
9+
} from 'prosemirror-search';
10+
11+
import {keydownHandler} from '#pm/keymap';
12+
import {type Command, Plugin, PluginKey, type PluginView} from '#pm/state';
13+
import type {EditorView} from '#pm/view';
14+
import {debounce} from 'src/lodash';
15+
import {type SearchPopupProps, renderSearchPopup} from 'src/search/SearchPopup';
16+
17+
import {getReactRendererFromState} from '../ReactRenderer';
18+
19+
import 'prosemirror-search/style/search.css';
20+
21+
const pluginKey = new PluginKey<SearchViewState>('search-view');
22+
23+
type TrMeta = {open: false} | {open: true; search: string};
24+
type SearchQueryConfig = ConstructorParameters<typeof SearchQuery>[0];
25+
26+
const openSearch: Command = (state, dispatch) => {
27+
const pluginState = pluginKey.getState(state);
28+
if (!pluginState?.open && dispatch) {
29+
const search = state.doc.textBetween(state.selection.from, state.selection.to, ' ');
30+
const meta: TrMeta = {open: true, search};
31+
dispatch(setSearchState(state.tr.setMeta(pluginKey, meta), new SearchQuery({search})));
32+
}
33+
return true;
34+
};
35+
36+
const hideSearch: Command = (state, dispatch) => {
37+
if (dispatch) {
38+
const meta: TrMeta = {open: false};
39+
dispatch(setSearchState(state.tr.setMeta(pluginKey, meta), new SearchQuery({search: ''})));
40+
}
41+
return true;
42+
};
43+
44+
type SearchState = {
45+
query: string;
46+
replacement?: string;
47+
};
48+
49+
type SearchViewState =
50+
| {
51+
open: false;
52+
}
53+
| {
54+
open: true;
55+
state: SearchState;
56+
};
57+
58+
export interface SearchViewPluginParams {
59+
anchorSelector: string;
60+
}
61+
62+
export const searchViewPlugin = (params: SearchViewPluginParams) => {
63+
return new Plugin<SearchViewState>({
64+
key: pluginKey,
65+
props: {
66+
handleKeyDown: keydownHandler({
67+
'Mod-f': openSearch,
68+
}),
69+
},
70+
state: {
71+
init: () => ({open: false}),
72+
apply(tr, value, _oldState, _newState) {
73+
const newValue = tr.getMeta(pluginKey);
74+
if (typeof newValue === 'object') return newValue;
75+
return value;
76+
},
77+
},
78+
view(view) {
79+
return new SeachPluginView(view, params);
80+
},
81+
});
82+
};
83+
84+
class SeachPluginView implements PluginView {
85+
private readonly _view: EditorView;
86+
private readonly _renderer;
87+
88+
private _viewState: SearchViewState | undefined;
89+
private _searchState: ReturnType<typeof getSearchState>;
90+
91+
private readonly _onSearchChangeDebounced;
92+
private readonly _onReplacementChangeDebounced;
93+
94+
constructor(view: EditorView, params: SearchViewPluginParams) {
95+
this._view = view;
96+
this._viewState = pluginKey.getState(view.state);
97+
this._searchState = getSearchState(view.state);
98+
99+
this._renderer = this._createRenderer(params);
100+
101+
this._onSearchChangeDebounced = debounce(this._handleSearchChange.bind(this));
102+
this._onReplacementChangeDebounced = debounce(this._handleReplacementChange.bind(this));
103+
}
104+
105+
update() {
106+
const newViewState = pluginKey.getState(this._view.state);
107+
console.log('searchViewPlugin view update', {newViewState});
108+
// const newSearchState = getSearchState(view.state);
109+
if (newViewState !== this._viewState) {
110+
this._viewState = newViewState;
111+
this._searchState = getSearchState(this._view.state);
112+
console.log('searchViewPlugin view update rerender', {
113+
viewState: this._viewState,
114+
searchState: this._searchState,
115+
});
116+
this._renderer.rerender();
117+
}
118+
}
119+
120+
destroy() {
121+
this._onSearchChangeDebounced.cancel();
122+
this._onReplacementChangeDebounced.cancel();
123+
this._renderer.remove();
124+
}
125+
126+
private _createRenderer(params: Pick<SearchViewPluginParams, 'anchorSelector'>) {
127+
return getReactRendererFromState(this._view.state).createItem('search-view', () => {
128+
const {_viewState: viewState, _searchState: searchState} = this;
129+
130+
console.log('searchViewPlugin render', {viewState, searchState});
131+
if (!viewState?.open || !searchState) return null;
132+
133+
const anchor = this._view.dom.ownerDocument.querySelector(params.anchorSelector);
134+
console.log('searchViewPlugin render anchor', {
135+
anchor,
136+
anchorSelector: params.anchorSelector,
137+
});
138+
139+
return renderSearchPopup({
140+
anchor,
141+
open: viewState.open,
142+
initial: searchState.query,
143+
onClose: this._onClose,
144+
onSearchPrev: this._onSearchPrev,
145+
onSearchNext: this._onSearchNext,
146+
onReplaceNext: this._onReplaceNext,
147+
onReplaceAll: this._onReplaceAll,
148+
onConfigChange: this._onConfigChange,
149+
onQueryChange: this._onSearchChangeDebounced,
150+
onReplacementChange: this._onReplacementChangeDebounced,
151+
});
152+
});
153+
}
154+
155+
private _handleSearchChange(value: string) {
156+
this._updateSearchState({search: value});
157+
}
158+
159+
private _handleReplacementChange(value: string) {
160+
this._updateSearchState({replace: value});
161+
}
162+
163+
private _updateSearchState(config: Partial<SearchQueryConfig>) {
164+
const {state, dispatch} = this._view;
165+
let prevConfig: SearchQueryConfig = {search: ''};
166+
167+
const prevSearchState = getSearchState(state);
168+
if (prevSearchState) {
169+
prevConfig = {
170+
search: prevSearchState.query.search,
171+
caseSensitive: prevSearchState.query.caseSensitive,
172+
wholeWord: prevSearchState.query.wholeWord,
173+
regexp: prevSearchState.query.regexp,
174+
replace: prevSearchState.query.replace,
175+
};
176+
}
177+
178+
console.log('searchViewPlugin updateSearchState', {prevSearchState, prevConfig, config});
179+
180+
dispatch(
181+
setSearchState(
182+
state.tr,
183+
new SearchQuery({
184+
...prevConfig,
185+
...config,
186+
}),
187+
),
188+
);
189+
}
190+
191+
private _onClose = () => {
192+
this._onSearchChangeDebounced.cancel();
193+
this._onReplacementChangeDebounced.cancel();
194+
hideSearch(this._view.state, this._view.dispatch);
195+
this._view.focus();
196+
};
197+
198+
private _onSearchPrev = () => {
199+
// this._view.focus();
200+
this._onSearchChangeDebounced.flush();
201+
findPrev(this._view.state, this._view.dispatch);
202+
// this._scrollToSelection();
203+
};
204+
205+
private _onSearchNext = () => {
206+
// this._view.focus();
207+
this._onSearchChangeDebounced.flush();
208+
findNext(this._view.state, this._view.dispatch);
209+
// this._scrollToSelection();
210+
};
211+
212+
private _onReplaceNext = () => {
213+
this._onSearchChangeDebounced.flush();
214+
this._onReplacementChangeDebounced.flush();
215+
replaceNext(this._view.state, this._view.dispatch);
216+
};
217+
218+
private _onReplaceAll = () => {
219+
this._onSearchChangeDebounced.flush();
220+
this._onReplacementChangeDebounced.flush();
221+
replaceAll(this._view.state, this._view.dispatch);
222+
};
223+
224+
private _onConfigChange: SearchPopupProps['onConfigChange'] = (config) => {
225+
console.log('searchViewPlugin onConfigChange', config);
226+
this._updateSearchState({
227+
wholeWord: config.wholeWord,
228+
caseSensitive: config.caseSensitive,
229+
});
230+
};
231+
232+
// private _scrollToSelection() {
233+
// this._view.dispatch(this._view.state.tr.scrollIntoView());
234+
// }
235+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './Search';

src/extensions/behavior/SelectionContext/index.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
AllSelection,
55
type EditorState,
66
Plugin,
7+
PluginKey,
78
type PluginSpec,
9+
type StateField,
810
TextSelection,
911
} from 'prosemirror-state';
1012
// @ts-ignore // TODO: fix cjs build
@@ -47,9 +49,15 @@ export const SelectionContext: ExtensionAuto<SelectionContextOptions> = (builder
4749
}
4850
};
4951

52+
const pluginKey = new PluginKey<PluginState>('selection-context');
53+
54+
type PluginState = {
55+
disabled: boolean;
56+
};
57+
5058
type TinyState = Pick<EditorState, 'doc' | 'selection'>;
5159

52-
class SelectionTooltip implements PluginSpec<unknown> {
60+
class SelectionTooltip implements PluginSpec<PluginState> {
5361
private destroyed = false;
5462

5563
private tooltip: TooltipView;
@@ -66,6 +74,10 @@ class SelectionTooltip implements PluginSpec<unknown> {
6674
this.tooltip = new TooltipView(actions, menuConfig, logger, options);
6775
}
6876

77+
get key(): PluginKey<PluginState> {
78+
return pluginKey;
79+
}
80+
6981
get props(): EditorProps {
7082
return {
7183
// same as keymap({})
@@ -101,6 +113,15 @@ class SelectionTooltip implements PluginSpec<unknown> {
101113
};
102114
}
103115

116+
get state(): StateField<PluginState> {
117+
return {
118+
init: () => ({disabled: false}),
119+
apply(tr) {
120+
return {disabled: Boolean(tr.getMeta('hide-selection-menu'))};
121+
},
122+
};
123+
}
124+
104125
view(view: EditorView) {
105126
this.update(view);
106127
return {
@@ -118,9 +139,11 @@ class SelectionTooltip implements PluginSpec<unknown> {
118139

119140
this.cancelTooltipHiding();
120141

142+
const hideFromTr = pluginKey.getState(view.state)?.disabled;
143+
121144
// Don't show tooltip if editor not mounted to the DOM
122145
// or when view is out of focus
123-
if (!view.dom.parentNode || !view.hasFocus()) {
146+
if (hideFromTr || !view.dom.parentNode || !view.hasFocus()) {
124147
this.tooltip.hide(view);
125148
return;
126149
}

src/extensions/behavior/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {FilePaste} from './FilePaste';
99
import {History, type HistoryOptions} from './History';
1010
import {Placeholder} from './Placeholder';
1111
import {type ReactRenderer, ReactRendererExtension} from './ReactRenderer';
12+
import {Search, type SearchOptions} from './Search';
1213
import {Selection} from './Selection';
1314
import {SelectionContext, type SelectionContextOptions} from './SelectionContext';
1415
import {SharedState} from './SharedState';
@@ -35,7 +36,7 @@ export type BehaviorPresetOptions = {
3536
placeholder?: PlaceholderOptions;
3637
reactRenderer: ReactRenderer;
3738
selectionContext?: SelectionContextOptions;
38-
39+
search?: SearchOptions;
3940
commandMenu?: CommandMenuOptions;
4041
mobile?: boolean;
4142
};
@@ -54,6 +55,7 @@ export const BehaviorPreset: ExtensionAuto<BehaviorPresetOptions> = (builder, op
5455
if (!opts.mobile) {
5556
builder.use(SelectionContext, opts.selectionContext ?? {});
5657
if (opts.commandMenu) builder.use(CommandMenu, opts.commandMenu);
58+
if (opts.search) builder.use(Search, opts.search);
5759
}
5860

5961
builder.use(FilePaste);

0 commit comments

Comments
 (0)