iTranslated by AI
Implementing a Simple Text Search Feature in CodeMirror
We have implemented a text search feature for the Zenn Markdown editor.
In this article, I will explain how to implement a minimal search feature based on @codemirror/search from CodeMirror.
Why we implemented the search feature
The initiative started from the following issue:
CodeMirror, which is used in the Zenn Markdown editor, uses a technique called virtual scrolling to render the DOM for performance optimization. Because virtual scrolling does not render the DOM for parts outside the visible range, the browser's built-in search function had the problem of not being able to search the entire text within the editor.
What is virtual scrolling?
Virtual scrolling is a technique for rendering and scrolling large amounts of data without sacrificing performance. The mechanism involves preparing an area to render the entire dataset while only rendering the portions currently visible on the browser screen.
The following articles are helpful for understanding how it works.
To solve this, the following options were considered:
- Disable CodeMirror's virtual scrolling or expand the rendering range.
- Use CodeMirror's search function.
However, since CodeMirror v6 does not provide options to disable virtual scrolling or expand the rendering range, option 1 was not viable.
Upon checking the implementation, it was found that in addition to the browser's visible range, it renders an extra 1000px in total, accounting for scroll direction and similar factors.
Marijn, the author of CodeMirror, also commented on the forum as follows:
Viewporting is a rather fundamental aspect of the way the library is designed (much of the complexity analysis in the implementation depends on it) and not something that can be turned off. (I keep getting CM5 support requests where people set viewportMargin: Infinity and then are surprised when the editor gets slow. I want to avoid that failure case this time.)
The way this breaks browser search is very annoying, but a trade-off that seems unavoidable with this design.
Therefore, we decided to go with option 2, "Use CodeMirror's search function."
Customizing @codemirror/search
If you use @codemirror/search as is, the following search interface will be displayed:

Default search panel
Originally, CodeMirror is an editor for source code, so it also included rich search features such as replacement and regular expression search. However, this felt somewhat excessive as a search feature for a writing editor, and at Zenn, we wanted to provide a simple text search.
Fortunately, the @codemirror/search Extension provides an option to insert a custom search panel, so we used that to adjust the look and functionality.

Customized search panel
The interface for the search panel is defined as follows:

https://codemirror.net/docs/ref/#search.search^config.createPanel
However, since I couldn't find any concrete implementation examples, I implemented it by stripping away unnecessary features based on the implementation of SearchPanel in @codemirror/search.
Marijn, the author of CodeMirror, also commented on the forum as follows:
The implementation of the built-in search panel is probably a good place to start.
https://discuss.codemirror.net/t/replace-default-search-panel-with-my-own-component/5885/2
Custom implementation of SearchPanel
Below is an example of the custom search panel implementation. It is long, so I will divide it to make the breaks easier to understand, but the entire code belongs to a single file.
import {
SearchQuery,
closeSearchPanel,
findNext,
findPrevious,
getSearchQuery,
search,
searchKeymap,
setSearchQuery,
} from '@codemirror/search';
import {
EditorView,
Panel,
ViewUpdate,
keymap,
runScopeHandlers,
} from '@codemirror/view';
class SearchPanel implements Panel {
searchField: HTMLInputElement;
dom: HTMLElement;
query: SearchQuery;
constructor(readonly view: EditorView) {
const query = (this.query = getSearchQuery(view.state));
this.commit = this.commit.bind(this);
// Initialize searchField
this.searchField = document.createElement('input');
this.searchField.value = query.search;
this.searchField.placeholder = 'Search';
this.searchField.setAttribute('aria-label', 'Search');
this.searchField.className = 'cm-textfield';
this.searchField.name = 'search';
this.searchField.setAttribute('form', '');
this.searchField.setAttribute('main-field', 'true');
this.searchField.onchange = this.commit;
this.searchField.onkeyup = this.commit;
function button(name: string, onclick: () => void) {
const button = document.createElement('button');
button.className = 'cm-button';
button.name = name;
button.onclick = onclick;
button.type = 'button';
button.setAttribute('aria-label', name);
return button;
}
// Initialize dom
this.dom = document.createElement('div');
this.dom.className = 'cm-search-custom'; // Specify a unique class to avoid applying default themes
this.dom.onkeydown = (e) => this.keydown(e);
this.dom.appendChild(this.searchField);
const nextButton = button('next', () => findNext(view));
this.dom.appendChild(nextButton);
const prevButton = button('prev', () => findPrevious(view));
this.dom.appendChild(prevButton);
const closeButton = button('close', () => closeSearchPanel(view));
this.dom.appendChild(closeButton);
}
SearchPanel is a custom search panel that retains only the necessary features from the SearchPanel in @codemirror/search. Since @codemirror/search is a JavaScript library, it generates the DOM using Web APIs.
getSearchQuery retrieves the query field of StateField<SearchState> defined in @codemirror/search. The query field (SearchQuery) is also used for managing the state of the default search panel and contains many fields, but in the custom search panel, we only use its search field. The search field is a string-type field that manages the search string.
commit() {
const query = new SearchQuery({
search: this.searchField.value,
});
if (!query.eq(this.query)) {
this.query = query;
this.view.dispatch({ effects: setSearchQuery.of(query) });
}
}
keydown(e: KeyboardEvent) {
if (runScopeHandlers(this.view, e, 'search-panel')) {
// Perform preventDefault when key bindings for searchKeymap are executed within the scope
e.preventDefault();
} else if (e.code == 'Enter' && e.target == this.searchField) {
e.preventDefault();
(e.shiftKey ? findPrevious : findNext)(this.view);
}
}
update(update: ViewUpdate) {
for (const tr of update.transactions)
for (const effect of tr.effects) {
if (effect.is(setSearchQuery) && !effect.value.eq(this.query))
this.setQuery(effect.value);
}
}
setQuery(query: SearchQuery) {
this.query = query;
this.searchField.value = query.search;
}
mount() {
this.searchField.select();
}
get top() {
return true; // Display at the position of .cm-panels-top
}
}
commit() monitors the input state and updates the state (SearchQuery) via this.view.dispatch when changes occur.
keydown() monitors key inputs and executes the Command (findNext or findPrevious) to perform the search when the Enter key is pressed.
update() calls setQuery() when the state of SearchQuery is changed from the outside. This might not be necessary for this custom search panel, but I kept the minimum requirements just in case.
Additionally, I kept what is necessary for good practices.
const baseTheme = EditorView.baseTheme({
// Override default styles
'.cm-panels.cm-panels-top': {
// Omitted as it is Zenn-specific styling
},
// Custom styles
'.cm-panel.cm-search-custom': {
// Omitted as it is Zenn-specific styling
},
});
baseTheme defines the style for our custom search panel. I have omitted the details as they are specific to Zenn, but the trick was to use the unique class name cm-search-custom to prevent the default styles from being applied.
export const searchPanel = [
// Insert our custom SearchPanel into the search Extension
search({ createPanel: (view) => new SearchPanel(view) }),
// Apply the custom SearchPanel style
baseTheme,
// Apply the default SearchPanel keymap
keymap.of(searchKeymap),
];
Finally, we export these together in a form that can be imported as an Extension.
With this, we have successfully implemented the custom search panel.
Conclusion
As always, CodeMirror's documentation is difficult to understand at first glance, but I was finally able to grasp it by reading it repeatedly while comparing it with the implementation and documentation. Also, I read the internal implementation of the Extension for the first time and began to understand the state management mechanism, so I would like to learn more about state management next time.
Discussion
いいね🙂ありがとうございました。
返礼のいいねを送ります😃
ありがとうございます!お役に立てたら嬉しいです 😄