Zenn Tech Blog

iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔍

Implementing a Simple Text Search Feature in CodeMirror

に公開2
Inside Zennの記事です

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:

https://github.com/zenn-dev/zenn-community/issues/605

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.

https://qiita.com/neer_chan/items/5ff1a82ed2fe121026d5

https://zenn.dev/so_nishimura/articles/6a934ad066bedf

To solve this, the following options were considered:

  1. Disable CodeMirror's virtual scrolling or expand the rendering range.
  2. 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.

https://github.com/codemirror/view/blob/6.26.3/src/viewstate.ts#L46-L47

Marijn, the author of CodeMirror, also commented on the forum as follows:

https://discuss.codemirror.net/t/viewport-issues-with-cm-6/3586/2

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.

GitHubで編集を提案
Zenn Tech Blog
Zenn Tech Blog

Discussion

zoldofzoldof

いいね🙂ありがとうございました。
返礼のいいねを送ります😃

1
bisquebisque

ありがとうございます!お役に立てたら嬉しいです 😄

1