Skip to content

Commit 9b3fe5d

Browse files
committed
upd(wysiwyg): add search and replace functionality to wysiwyg mode [1]
1 parent feb1d11 commit 9b3fe5d

File tree

7 files changed

+177
-33
lines changed

7 files changed

+177
-33
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export class FocusManager {
2+
#doc: Document;
3+
#previousFocused: HTMLElement | null = null;
4+
5+
constructor(doc: Document = document) {
6+
this.#doc = doc;
7+
}
8+
9+
storeFocus() {
10+
this.#previousFocused = this.#doc.activeElement as HTMLElement | null;
11+
}
12+
13+
restoreFocus(options?: FocusOptions) {
14+
this.#previousFocused?.focus?.(options);
15+
}
16+
}

src/extensions/behavior/Search/SearchViewPlugin.ts

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,40 @@ import {
22
SearchQuery,
33
findNext,
44
findPrev,
5+
getMatchHighlights,
56
getSearchState,
67
replaceAll,
78
replaceNext,
89
setSearchState,
910
} from 'prosemirror-search';
1011

1112
import {keydownHandler} from '#pm/keymap';
12-
import {type Command, Plugin, PluginKey, type PluginView} from '#pm/state';
13+
import {
14+
type Command,
15+
type EditorState,
16+
Plugin,
17+
PluginKey,
18+
type PluginView,
19+
type Transaction,
20+
} from '#pm/state';
1321
import type {EditorView} from '#pm/view';
1422
import {debounce} from 'src/lodash';
15-
import {type SearchPopupProps, renderSearchPopup} from 'src/search/SearchPopup';
23+
import {type SearchCounter, type SearchPopupProps, renderSearchPopup} from 'src/search';
1624

1725
import {getReactRendererFromState} from '../ReactRenderer';
26+
import {hideSelectionMenu} from '../SelectionContext';
27+
28+
import {FocusManager} from './FocusManager';
1829

19-
import 'prosemirror-search/style/search.css';
30+
import './search-plugin.scss';
2031

2132
const pluginKey = new PluginKey<SearchViewState>('search-view');
2233

34+
const SearchClassName = {
35+
Match: 'ProseMirror-search-match',
36+
ActiveMatch: 'ProseMirror-active-search-match',
37+
};
38+
2339
type TrMeta = {open: false} | {open: true; search: string};
2440
type SearchQueryConfig = ConstructorParameters<typeof SearchQuery>[0];
2541

@@ -28,7 +44,12 @@ const openSearch: Command = (state, dispatch) => {
2844
if (!pluginState?.open && dispatch) {
2945
const search = state.doc.textBetween(state.selection.from, state.selection.to, ' ');
3046
const meta: TrMeta = {open: true, search};
31-
dispatch(setSearchState(state.tr.setMeta(pluginKey, meta), new SearchQuery({search})));
47+
dispatch(
48+
setSearchState(
49+
hideSelectionMenu(state.tr.setMeta(pluginKey, meta)),
50+
new SearchQuery({search}),
51+
),
52+
);
3253
}
3354
return true;
3455
};
@@ -84,7 +105,9 @@ export const searchViewPlugin = (params: SearchViewPluginParams) => {
84105
class SeachPluginView implements PluginView {
85106
private readonly _view: EditorView;
86107
private readonly _renderer;
108+
private readonly _focusManager: FocusManager;
87109

110+
private _counter: SearchCounter;
88111
private _viewState: SearchViewState | undefined;
89112
private _searchState: ReturnType<typeof getSearchState>;
90113

@@ -95,18 +118,26 @@ class SeachPluginView implements PluginView {
95118
this._view = view;
96119
this._viewState = pluginKey.getState(view.state);
97120
this._searchState = getSearchState(view.state);
121+
this._counter = getCounter(view.state);
98122

99123
this._renderer = this._createRenderer(params);
124+
this._focusManager = new FocusManager(view.dom.ownerDocument);
100125

101126
this._onSearchChangeDebounced = debounce(this._handleSearchChange.bind(this));
102127
this._onReplacementChangeDebounced = debounce(this._handleReplacementChange.bind(this));
103128
}
104129

105130
update() {
131+
const counter = getCounter(this._view.state);
106132
const newViewState = pluginKey.getState(this._view.state);
107133
console.log('searchViewPlugin view update', {newViewState});
108134
// const newSearchState = getSearchState(view.state);
109-
if (newViewState !== this._viewState) {
135+
if (
136+
newViewState !== this._viewState ||
137+
counter.total !== this._counter.total ||
138+
counter.current !== this._counter.current
139+
) {
140+
this._counter = counter;
110141
this._viewState = newViewState;
111142
this._searchState = getSearchState(this._view.state);
112143
console.log('searchViewPlugin view update rerender', {
@@ -139,6 +170,7 @@ class SeachPluginView implements PluginView {
139170
return renderSearchPopup({
140171
anchor,
141172
open: viewState.open,
173+
counter: this._counter,
142174
initial: searchState.query,
143175
onClose: this._onClose,
144176
onSearchPrev: this._onSearchPrev,
@@ -196,29 +228,25 @@ class SeachPluginView implements PluginView {
196228
};
197229

198230
private _onSearchPrev = () => {
199-
// this._view.focus();
200231
this._onSearchChangeDebounced.flush();
201-
findPrev(this._view.state, this._view.dispatch);
202-
// this._scrollToSelection();
232+
this._preserveFocus(wrapCommand(findPrev, hideSelectionMenu));
203233
};
204234

205235
private _onSearchNext = () => {
206-
// this._view.focus();
207236
this._onSearchChangeDebounced.flush();
208-
findNext(this._view.state, this._view.dispatch);
209-
// this._scrollToSelection();
237+
this._preserveFocus(wrapCommand(findNext, hideSelectionMenu));
210238
};
211239

212240
private _onReplaceNext = () => {
213241
this._onSearchChangeDebounced.flush();
214242
this._onReplacementChangeDebounced.flush();
215-
replaceNext(this._view.state, this._view.dispatch);
243+
this._preserveFocus(wrapCommand(replaceNext, hideSelectionMenu));
216244
};
217245

218246
private _onReplaceAll = () => {
219247
this._onSearchChangeDebounced.flush();
220248
this._onReplacementChangeDebounced.flush();
221-
replaceAll(this._view.state, this._view.dispatch);
249+
this._preserveFocus(wrapCommand(replaceAll, hideSelectionMenu));
222250
};
223251

224252
private _onConfigChange: SearchPopupProps['onConfigChange'] = (config) => {
@@ -229,7 +257,38 @@ class SeachPluginView implements PluginView {
229257
});
230258
};
231259

232-
// private _scrollToSelection() {
233-
// this._view.dispatch(this._view.state.tr.scrollIntoView());
234-
// }
260+
private _preserveFocus(command: Command) {
261+
this._focusManager.storeFocus();
262+
this._view.focus();
263+
command(this._view.state, this._view.dispatch, this._view);
264+
this._focusManager.restoreFocus({preventScroll: true});
265+
}
266+
}
267+
268+
function wrapCommand(command: Command, wrapper: (tr: Transaction) => void): Command {
269+
return (state, dispatch, view) => {
270+
return command(
271+
state,
272+
dispatch
273+
? (tr) => {
274+
wrapper(tr);
275+
return dispatch(tr);
276+
}
277+
: undefined,
278+
view,
279+
);
280+
};
281+
}
282+
283+
function getCounter(state: EditorState): SearchCounter {
284+
const decoSet = getMatchHighlights(state);
285+
const searchDecos = decoSet.find(undefined, undefined, () => true);
286+
const activeIndex = searchDecos.findIndex((deco) => {
287+
const d = deco as {type?: {attrs?: Record<string, any>}};
288+
return d.type?.attrs?.class === SearchClassName.ActiveMatch;
289+
});
290+
return {
291+
total: searchDecos.length,
292+
current: activeIndex + 1,
293+
};
235294
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.ProseMirror-search-match {
2+
background-color: var(--g-color-base-info-light);
3+
}
4+
5+
.ProseMirror-active-search-match {
6+
background-color: var(--g-color-base-info-heavy);
7+
}

src/extensions/behavior/SelectionContext/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type PluginSpec,
99
type StateField,
1010
TextSelection,
11+
type Transaction,
1112
} from 'prosemirror-state';
1213
// @ts-ignore // TODO: fix cjs build
1314
import {hasParentNode} from 'prosemirror-utils';
@@ -49,6 +50,12 @@ export const SelectionContext: ExtensionAuto<SelectionContextOptions> = (builder
4950
}
5051
};
5152

53+
const HideMetaKey = 'hide-selection-menu';
54+
55+
export const hideSelectionMenu = (tr: Transaction) => {
56+
return tr.setMeta(HideMetaKey, true);
57+
};
58+
5259
const pluginKey = new PluginKey<PluginState>('selection-context');
5360

5461
type PluginState = {
@@ -117,7 +124,7 @@ class SelectionTooltip implements PluginSpec<PluginState> {
117124
return {
118125
init: () => ({disabled: false}),
119126
apply(tr) {
120-
return {disabled: Boolean(tr.getMeta('hide-selection-menu'))};
127+
return {disabled: Boolean(tr.getMeta(HideMetaKey))};
121128
},
122129
};
123130
}

src/markup/codemirror/search-plugin/plugin.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
searchPanelOpen,
1212
setSearchQuery,
1313
} from '@codemirror/search';
14+
// import type {EditorState} from '@codemirror/state';
1415
import {
1516
type EditorView,
1617
type PluginValue,
@@ -19,15 +20,17 @@ import {
1920
keymap,
2021
} from '@codemirror/view';
2122

23+
import type {MarkdownEditorMode} from 'src/bundle';
24+
import type {EventMap} from 'src/bundle/Editor';
25+
import type {RendererItem} from 'src/extensions';
26+
import {debounce} from 'src/lodash';
2227
import {renderSearchPopup} from 'src/search';
28+
import type {Receiver} from 'src/utils';
2329

24-
import type {MarkdownEditorMode} from '../../../bundle';
25-
import type {EventMap} from '../../../bundle/Editor';
26-
import type {RendererItem} from '../../../extensions';
27-
import {debounce} from '../../../lodash';
28-
import type {Receiver} from '../../../utils';
2930
import {ReactRendererFacet} from '../react-facet';
3031

32+
import {searchTheme} from './theme';
33+
3134
type SearchQueryConfig = ConstructorParameters<typeof SearchQuery>[0];
3235

3336
const INPUT_DELAY = 200;
@@ -46,6 +49,7 @@ export const SearchPanelPlugin = (params: SearchPanelPluginParams) =>
4649

4750
anchor: HTMLElement | null;
4851
renderer: RendererItem | null;
52+
// counter: SearchCounter;
4953
searchConfig: SearchQueryConfig = {
5054
search: '',
5155
caseSensitive: false,
@@ -62,6 +66,7 @@ export const SearchPanelPlugin = (params: SearchPanelPluginParams) =>
6266
this.renderer = null;
6367
this.params = params;
6468
this.receiver = params.receiver;
69+
// this.counter = getCounter(view.state);
6570

6671
this.handleClose = this.handleClose.bind(this);
6772
this.handleChange = this.handleChange.bind(this);
@@ -84,13 +89,15 @@ export const SearchPanelPlugin = (params: SearchPanelPluginParams) =>
8489

8590
if (isPanelOpen && !this.renderer) {
8691
const initial = getSearchQuery(update.state);
92+
// this.counter = getCounter(update.state);
8793
this.anchor = document.querySelector(this.params.anchorSelector);
8894
this.renderer = this.view.state
8995
.facet(ReactRendererFacet)
9096
.createItem('cm-search', () =>
9197
renderSearchPopup({
9298
initial,
9399
open: true,
100+
// counter: this.counter,
94101
anchor: this.anchor,
95102
onQueryChange: this.handleChange,
96103
onClose: this.handleClose,
@@ -102,6 +109,7 @@ export const SearchPanelPlugin = (params: SearchPanelPluginParams) =>
102109
}),
103110
);
104111
} else if (!isPanelOpen && this.renderer) {
112+
// this.counter = getCounter(update.state);
105113
this.renderer?.remove();
106114
this.renderer = null;
107115
}
@@ -165,6 +173,7 @@ export const SearchPanelPlugin = (params: SearchPanelPluginParams) =>
165173
{
166174
provide: () => [
167175
keymap.of(searchKeymap),
176+
searchTheme,
168177
search({
169178
createPanel: () => ({
170179
// Create an empty search panel
@@ -174,3 +183,28 @@ export const SearchPanelPlugin = (params: SearchPanelPluginParams) =>
174183
],
175184
},
176185
);
186+
187+
// function getCounter(state: EditorState): SearchCounter {
188+
// const query = getSearchQuery(state);
189+
// const cursor = query.getCursor(state);
190+
191+
// let current = -1;
192+
// const matches: {from: number; to: number}[] = [];
193+
// (() => {
194+
// let res = cursor.next();
195+
// let idx = 0;
196+
// while (!res.done) {
197+
// matches.push(res.value);
198+
199+
// const a = state.doc.slice(res.value.from, res.value.to).iter()
200+
201+
// idx++;
202+
// res = cursor.next();
203+
// }
204+
// })();
205+
206+
// return {
207+
// current: 0,
208+
// total: matches.length,
209+
// };
210+
// }
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {EditorView} from '#cm/view';
2+
3+
export const searchTheme = EditorView.baseTheme({
4+
'&light, &dark': {
5+
'& .cm-searchMatch': {
6+
backgroundColor: 'var(--g-color-base-info-light)',
7+
},
8+
'& .cm-searchMatch-selected': {
9+
backgroundColor: 'var(--g-color-base-info-heavy)',
10+
},
11+
},
12+
});

0 commit comments

Comments
 (0)