Skip to content

Commit d168ab6

Browse files
committed
feat(wysiwyg): add search and replace functionality to wysiwyg mode
1 parent 13e2ec9 commit d168ab6

File tree

19 files changed

+429
-3
lines changed

19 files changed

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