Skip to content

Commit 6b35e14

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

File tree

10 files changed

+187
-121
lines changed

10 files changed

+187
-121
lines changed
Lines changed: 41 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,24 @@
1-
import {
2-
SearchQuery,
3-
findNext,
4-
findPrev,
5-
getMatchHighlights,
6-
getSearchState,
7-
replaceAll,
8-
replaceNext,
9-
setSearchState,
10-
} from 'prosemirror-search';
11-
12-
import {keydownHandler} from '#pm/keymap';
13-
import {
14-
type Command,
15-
type EditorState,
16-
Plugin,
17-
PluginKey,
18-
type PluginView,
19-
type Transaction,
20-
} from '#pm/state';
1+
import {SearchQuery, getSearchState, setSearchState} from 'prosemirror-search';
2+
3+
import {type Command, Plugin, type PluginView} from '#pm/state';
214
import type {EditorView} from '#pm/view';
225
import {debounce} from 'src/lodash';
236
import {type SearchCounter, type SearchPopupProps, renderSearchPopup} from 'src/search';
247

258
import {getReactRendererFromState} from '../ReactRenderer';
26-
import {hideSelectionMenu} from '../SelectionContext';
279

28-
import {FocusManager} from './FocusManager';
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';
2917

3018
import './search-plugin.scss';
3119

32-
const pluginKey = new PluginKey<SearchViewState>('search-view');
33-
34-
const SearchClassName = {
35-
Match: 'ProseMirror-search-match',
36-
ActiveMatch: 'ProseMirror-active-search-match',
37-
};
38-
39-
type TrMeta = {open: false} | {open: true; search: string};
4020
type SearchQueryConfig = ConstructorParameters<typeof SearchQuery>[0];
4121

42-
const openSearch: Command = (state, dispatch) => {
43-
const pluginState = pluginKey.getState(state);
44-
if (!pluginState?.open && dispatch) {
45-
const search = state.doc.textBetween(state.selection.from, state.selection.to, ' ');
46-
const meta: TrMeta = {open: true, search};
47-
dispatch(
48-
setSearchState(
49-
hideSelectionMenu(state.tr.setMeta(pluginKey, meta)),
50-
new SearchQuery({search}),
51-
),
52-
);
53-
}
54-
return true;
55-
};
56-
57-
const hideSearch: Command = (state, dispatch) => {
58-
if (dispatch) {
59-
const meta: TrMeta = {open: false};
60-
dispatch(setSearchState(state.tr.setMeta(pluginKey, meta), new SearchQuery({search: ''})));
61-
}
62-
return true;
63-
};
64-
65-
type SearchState = {
66-
query: string;
67-
replacement?: string;
68-
};
69-
70-
type SearchViewState =
71-
| {
72-
open: false;
73-
}
74-
| {
75-
open: true;
76-
state: SearchState;
77-
};
78-
7922
export interface SearchViewPluginParams {
8023
anchorSelector: string;
8124
}
@@ -84,14 +27,12 @@ export const searchViewPlugin = (params: SearchViewPluginParams) => {
8427
return new Plugin<SearchViewState>({
8528
key: pluginKey,
8629
props: {
87-
handleKeyDown: keydownHandler({
88-
'Mod-f': openSearch,
89-
}),
30+
handleKeyDown: searchKeyHandler,
9031
},
9132
state: {
9233
init: () => ({open: false}),
9334
apply(tr, value, _oldState, _newState) {
94-
const newValue = tr.getMeta(pluginKey);
35+
const newValue = tr.getMeta(pluginKey) as SearchViewState | undefined;
9536
if (typeof newValue === 'object') return newValue;
9637
return value;
9738
},
@@ -106,8 +47,10 @@ class SeachPluginView implements PluginView {
10647
private readonly _view: EditorView;
10748
private readonly _renderer;
10849
private readonly _focusManager: FocusManager;
50+
private readonly _viewDomTrackerDispose;
10951

11052
private _counter: SearchCounter;
53+
private _isDomConnected: boolean;
11154
private _viewState: SearchViewState | undefined;
11255
private _searchState: ReturnType<typeof getSearchState>;
11356

@@ -119,9 +62,14 @@ class SeachPluginView implements PluginView {
11962
this._viewState = pluginKey.getState(view.state);
12063
this._searchState = getSearchState(view.state);
12164
this._counter = getCounter(view.state);
65+
this._isDomConnected = view.dom.ownerDocument.contains(view.dom);
12266

12367
this._renderer = this._createRenderer(params);
12468
this._focusManager = new FocusManager(view.dom.ownerDocument);
69+
this._viewDomTrackerDispose = startTracking(view.dom, {
70+
onConnect: this._onEditorViewDomConnected,
71+
onDisconnect: this._onEditorViewDomDisconnected,
72+
});
12573

12674
this._onSearchChangeDebounced = debounce(this._handleSearchChange.bind(this));
12775
this._onReplacementChangeDebounced = debounce(this._handleReplacementChange.bind(this));
@@ -130,8 +78,6 @@ class SeachPluginView implements PluginView {
13078
update() {
13179
const counter = getCounter(this._view.state);
13280
const newViewState = pluginKey.getState(this._view.state);
133-
console.log('searchViewPlugin view update', {newViewState});
134-
// const newSearchState = getSearchState(view.state);
13581
if (
13682
newViewState !== this._viewState ||
13783
counter.total !== this._counter.total ||
@@ -140,10 +86,6 @@ class SeachPluginView implements PluginView {
14086
this._counter = counter;
14187
this._viewState = newViewState;
14288
this._searchState = getSearchState(this._view.state);
143-
console.log('searchViewPlugin view update rerender', {
144-
viewState: this._viewState,
145-
searchState: this._searchState,
146-
});
14789
this._renderer.rerender();
14890
}
14991
}
@@ -152,20 +94,30 @@ class SeachPluginView implements PluginView {
15294
this._onSearchChangeDebounced.cancel();
15395
this._onReplacementChangeDebounced.cancel();
15496
this._renderer.remove();
97+
this._viewDomTrackerDispose();
15598
}
15699

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+
157110
private _createRenderer(params: Pick<SearchViewPluginParams, 'anchorSelector'>) {
158111
return getReactRendererFromState(this._view.state).createItem('search-view', () => {
159-
const {_viewState: viewState, _searchState: searchState} = this;
112+
const {
113+
_viewState: viewState,
114+
_searchState: searchState,
115+
_isDomConnected: domConnected,
116+
} = this;
160117

161-
console.log('searchViewPlugin render', {viewState, searchState});
162-
if (!viewState?.open || !searchState) return null;
118+
if (!domConnected || !viewState?.open || !searchState) return null;
163119

164120
const anchor = this._view.dom.ownerDocument.querySelector(params.anchorSelector);
165-
console.log('searchViewPlugin render anchor', {
166-
anchor,
167-
anchorSelector: params.anchorSelector,
168-
});
169121

170122
return renderSearchPopup({
171123
anchor,
@@ -207,8 +159,6 @@ class SeachPluginView implements PluginView {
207159
};
208160
}
209161

210-
console.log('searchViewPlugin updateSearchState', {prevSearchState, prevConfig, config});
211-
212162
dispatch(
213163
setSearchState(
214164
state.tr,
@@ -223,34 +173,33 @@ class SeachPluginView implements PluginView {
223173
private _onClose = () => {
224174
this._onSearchChangeDebounced.cancel();
225175
this._onReplacementChangeDebounced.cancel();
226-
hideSearch(this._view.state, this._view.dispatch);
176+
closeSearch(this._view.state, this._view.dispatch);
227177
this._view.focus();
228178
};
229179

230180
private _onSearchPrev = () => {
231181
this._onSearchChangeDebounced.flush();
232-
this._preserveFocus(wrapCommand(findPrev, hideSelectionMenu));
182+
this._preserveFocus(findPrev);
233183
};
234184

235185
private _onSearchNext = () => {
236186
this._onSearchChangeDebounced.flush();
237-
this._preserveFocus(wrapCommand(findNext, hideSelectionMenu));
187+
this._preserveFocus(findNext);
238188
};
239189

240190
private _onReplaceNext = () => {
241191
this._onSearchChangeDebounced.flush();
242192
this._onReplacementChangeDebounced.flush();
243-
this._preserveFocus(wrapCommand(replaceNext, hideSelectionMenu));
193+
this._preserveFocus(replaceNext);
244194
};
245195

246196
private _onReplaceAll = () => {
247197
this._onSearchChangeDebounced.flush();
248198
this._onReplacementChangeDebounced.flush();
249-
this._preserveFocus(wrapCommand(replaceAll, hideSelectionMenu));
199+
this._preserveFocus(replaceAll);
250200
};
251201

252202
private _onConfigChange: SearchPopupProps['onConfigChange'] = (config) => {
253-
console.log('searchViewPlugin onConfigChange', config);
254203
this._updateSearchState({
255204
wholeWord: config.wholeWord,
256205
caseSensitive: config.caseSensitive,
@@ -264,31 +213,3 @@ class SeachPluginView implements PluginView {
264213
this._focusManager.restoreFocus({preventScroll: true});
265214
}
266215
}
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-
};
294-
}
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: 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+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type SearchViewState = {open: boolean};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
type TrackingOptions = {
2+
onConnect?: () => void;
3+
onDisconnect?: () => void;
4+
};
5+
6+
type Dispose = () => void;
7+
8+
export function startTracking(elem: HTMLElement, options: TrackingOptions): Dispose {
9+
const document = elem.ownerDocument;
10+
11+
let connected = document.contains(elem);
12+
13+
const observer = new MutationObserver(() => {
14+
if (connected) {
15+
if (!document.contains(elem)) {
16+
connected = false;
17+
options.onDisconnect?.();
18+
}
19+
} else if (document.contains(elem)) {
20+
connected = true;
21+
options.onConnect?.();
22+
}
23+
});
24+
25+
observer.observe(document.body, {childList: true, subtree: true});
26+
27+
return () => {
28+
observer.disconnect();
29+
};
30+
}

0 commit comments

Comments
 (0)