検索機能のスクロール精度を改善した話

検索機能のスクロール精度を改善した話

  • 日本語
  • Rust
  • JavaScript

#はじめに

検索機能、便利だよね。

このサイトには snacks.nvim 風の検索UIがある。/ キーを押すとモーダルが開いて、キーワードを入力すると記事一覧から該当箇所を探してくれる。選択してEnterを押せば、その記事のその行にジャンプ。完璧だ。

…と思っていた。

本番環境にデプロイしたら、全然違う場所にスクロールする。

83行目のキーワードを選んだのに、49行目の同じキーワードで止まる。なんなら正しく動く時もある。不安定。気まぐれ。まるで猫のようだ。

これはその修正の記録である。

#問題その1: スマホで見出しの左に謎の空白

まず気づいたのは、スマホサイズで見出しの左に謎の空白が発生していることだった。

パソコンでは問題ない。スマホでだけ発生する。デベロッパーツールでレスポンシブモードにして確認すると、確かに見出しの左に余計な padding-left がある。

原因はCSSだった。

css
/* デスクトップ用 */
.main-content.page-article > h2,
.main-content.page-article > h3,
.main-content.page-article > h4 {
    padding-left: 2em;  /* page-articleのみに適用 */
}

/* モバイル用(問題のコード) */
.main-content > h2,
.main-content > h3,
.main-content > h4 {
    padding-left: 1.5em;  /* すべてのページに適用されてしまう */
}

デスクトップ用は .page-article という条件がついているのに、モバイル用は条件なし。つまりAboutページ(.page-home)でも見出しに余分なパディングが適用されていた。

修正は簡単だった。

css
/* モバイル用(修正後) */
.main-content.page-article > h2,
.main-content.page-article > h3,
.main-content.page-article > h4 {
    padding-left: 1.5em;  /* 記事ページのみに適用 */
}

でもこれは序章に過ぎなかった。

#問題その2: 行番号が合わない

検索モーダルには行番号が表示される。slug:83 のように、記事のスラッグと行番号が表示される。

ところがこの行番号、実際のページの行番号と全然合っていなかった。

このサイトではCSSカウンターを使って行番号を表示している。.main-content 直下のブロック要素(h1, h2, p, ul, pre, table など)をカウントして、疑似要素で表示する仕組みだ。

css
.main-content > h1,
.main-content > h2,
.main-content > p,
.main-content > ul,
/* ... */
{
    position: relative;
    counter-increment: line-number;
}

.main-content > h1::before,
.main-content > h2::before,
.main-content > p::before,
/* ... */
{
    content: counter(line-number);
    /* 行番号のスタイル */
}

一方、検索インデックスは plain_content というMarkdownから抽出したプレーンテキストを行で分割して生成していた。

rust
// 旧実装(問題のコード)
let lines: Vec<SearchLine> = article
    .plain_content
    .lines()
    .enumerate()
    .filter(|(_, line)| !line.trim().is_empty())
    .map(|(i, line)| SearchLine {
        num: i + 1,
        text: line.trim().to_string(),
    })
    .collect();

問題は、plain_content がテキストをスペース区切りで連結しただけのもので、実際のDOM構造(ブロック要素ごと)とは全く対応していなかったことだ。

Markdownの段落とHTMLの <p> タグは1対1で対応するとは限らない。コードブロック、リスト、テーブルなど、1つのMarkdown要素が複数のHTML要素になったり、その逆もある。

#解決策: content_blocks の導入

そこで、plain_content を廃止し、content_blocks という新しい構造を導入した。

rust
/// 検索用のブロック要素(DOMの行番号と対応)
#[derive(Debug, Clone)]
pub struct ContentBlock {
    pub line_num: usize,
    pub text: String,
}

pub struct Article {
    pub metadata: Option<MetaData>,
    pub content_html: String,
    pub content_blocks: Vec<ContentBlock>,  // plain_contentの代わり
    // ...
}

Markdown変換時に、CSSカウンター対象のブロック要素(Paragraph, Heading, List, BlockQuote, CodeBlock, Table)ごとにテキストを収集し、行番号を割り当てる。

rust
// ブロック要素の開始・終了をトラッキング
match &event {
    Event::Start(Tag::Heading { .. })
    | Event::Start(Tag::Paragraph)
    | Event::Start(Tag::List(_))
    | Event::Start(Tag::BlockQuote(_))
    | Event::Start(Tag::CodeBlock(_))
    | Event::Start(Tag::Table(_)) => {
        if block_depth == 0 {
            block_line_num += 1;
            current_block_text.clear();
            in_block = true;
        }
        block_depth += 1;
    }
    Event::End(TagEnd::Heading(_))
    | Event::End(TagEnd::Paragraph)
    // ...
    => {
        block_depth -= 1;
        if block_depth == 0 && in_block {
            let text = current_block_text.trim().to_string();
            if !text.is_empty() {
                content_blocks.push(ContentBlock {
                    line_num: block_line_num,
                    text,
                });
            }
            in_block = false;
        }
    }
    // ...
}

これで検索インデックスの行番号がDOMの行番号と一致する…はずだった。

#問題その3: オフセットの存在

テストしてみると、まだズレている。検索インデックスの行1が「はじめに」なのに、DOMの行1は記事タイトルだった。

原因は、main-content 内にMarkdown本文以外のHTML要素があったことだ。

html
<main class="main-content page-article">
    <img src="...">              <!-- CSSカウンター対象外 -->
    <h1>記事タイトル</h1>        <!-- カウンター行1 -->
    <ul class="badge-list">...</ul>  <!-- カウンター行2(言語バッジ) -->
    <ul class="badge-list">...</ul>  <!-- カウンター行3(タグバッジ) -->
    <h2>はじめに</h2>            <!-- カウンター行4(Markdown本文開始) -->
    <p>本文...</p>              <!-- カウンター行5 -->
    ...
</main>

Markdown本文の前に3つの要素があった。タイトル、言語バッジ、タグバッジ。

解決は単純。検索インデックス生成時にオフセット3を加算する。

rust
// オフセット: main-content内でMarkdown本文の前にある要素
// - h1タイトル: 1
// - ul.badge-list(言語バッジ): 1
// - ul.badge-list(タグバッジ): 1
// 合計: 3
const LINE_NUM_OFFSET: usize = 3;
let lines: Vec<SearchLine> = article
    .content_blocks
    .iter()
    .map(|block| SearchLine {
        num: block.line_num + LINE_NUM_OFFSET,
        text: block.text.clone(),
    })
    .collect();

これで行番号は完全に一致した。検索モーダルに表示される行番号と、実際のページの行番号が同じになった。

でもまだ問題は終わっていなかった。

#問題その4: 同じキーワードが複数箇所にある

行番号は合っている。でも83行目のキーワードを選んでも、49行目の同じキーワードにスクロールしてしまう。

原因は、ハイライト処理のターゲット特定ロジックにあった。

旧実装では、テキストマッチングでターゲットを探していた。

javascript
// 旧実装(問題のコード)
const isTextMatch = targetLineText &&
    normalizedNodeText.includes(normalizedTarget.substring(0, 30));

検索結果から渡された lineText(選択した行のテキスト)の最初の30文字が含まれるノードを探す。見つかったらそこがターゲット。

問題は、同じキーワードが複数箇所にある場合、最初に見つかったノードがターゲットになってしまうことだ。「Rust」というキーワードが記事内に50回出てきたら、どれを選んでも最初の「Rust」にスクロールしてしまう。

#最終解決: lineNum でDOMを直接特定する

テキストマッチングに頼るのをやめた。代わりに、lineNum を使ってDOM上の対応する要素を直接特定する。

javascript
// 新実装
const applyHighlight = (query, targetLineText, targetLineNum) => {
    const mainContent = document.querySelector('.main-content');
    if (!mainContent || !query) return;

    // CSSカウンター対象のブロック要素を取得(行番号と対応)
    const blockElements = Array.from(mainContent.querySelectorAll(
        ':scope > h1, :scope > h2, :scope > h3, :scope > h4, ' +
        ':scope > p, :scope > ul, :scope > ol, :scope > blockquote, ' +
        ':scope > pre, :scope > table, :scope > hr, ' +
        ':scope > div.code-block-wrapper'
    ));

    // ターゲット行の要素を特定(lineNumは1始まり)
    const targetElement = targetLineNum > 0 && targetLineNum <= blockElements.length
        ? blockElements[targetLineNum - 1]
        : null;

    // ... テキストノードを走査してハイライト ...

    while (node = walker.nextNode()) {
        if (node.textContent.toLowerCase().includes(lowerQuery)) {
            // このノードがターゲット要素内にあるかチェック
            const isInTargetElement = targetElement && targetElement.contains(node);
            nodesToHighlight.push({
                node: node,
                isInTargetElement: isInTargetElement
            });
        }
    }

    // ターゲット要素内の最初のハイライトを記録
    if (item.isInTargetElement && targetHighlightIndex === -1) {
        targetHighlightIndex = highlightCount;
    }
};

ポイントは以下の3つ。

  1. CSSカウンター対象と同じセレクタでブロック要素を取得: :scope > h1, :scope > h2, ...main-content 直下の要素を配列として取得
  2. lineNum で要素を直接特定: blockElements[targetLineNum - 1] で行番号に対応する要素を取得
  3. contains() でターゲット判定: targetElement.contains(node) でテキストノードがターゲット要素内にあるかを判定

これにより、同じキーワードが100箇所あっても、選択した行番号のDOM要素内のハイライトに正確にスクロールするようになった。

#問題その5: コードブロックラッパーの存在

「よし、これで完璧だ」と思った翌日。またズレが発覚した。

45行目のキーワードを選んだのに、37行目にスクロールする。8行のズレ。記事内のコードブロックの数と一致している。怪しい。

原因を調べると、コードブロックのHTML構造に問題があった。

html
<!-- 実際の構造 -->
<div class="code-block-wrapper">
    <div class="code-block-header">...</div>
    <pre><code>...</code></pre>
</div>

このサイトでは、コードブロックにヘッダーバー(言語名とコピーボタン)を付けている。そのため、<pre> タグは .code-block-wrapper でラップされ、main-content の直接の子要素ではなくなっていた。

CSSカウンターの設定を確認すると…

css
/* counter-incrementには .code-block-wrapper を追加済み */
.main-content > .code-block-wrapper {
    counter-increment: line-number;
}

/* ::beforeにも追加済み */
.main-content > .code-block-wrapper::before {
    content: counter(line-number);
}

ここまでは良い。でも、他のCSSルールを見ると…

css
/* :hover::before に .code-block-wrapper がない! */
.main-content > h1:hover::before,
.main-content > h2:hover::before,
/* ... */
.main-content > pre:hover::before {  /* preは直接の子要素じゃない */
    color: var(--accent-cyan);
}

/* .current-line::before にも .code-block-wrapper がない! */
.main-content > pre.current-line::before {
    /* スタイル */
}

ホバー時のスタイルと現在行のスタイルに .code-block-wrapper が追加されていなかった。これ自体は行番号の計算には影響しないが、関連するセレクタの一貫性の問題だ。

そして真の問題は、JavaScriptの blockElements セレクタと CSSカウンターの対象が微妙にズレていたこと。両方に .code-block-wrapper を含めることで、ようやく完全に一致した。

css
/* 修正後: すべての関連ルールに .code-block-wrapper を追加 */
.main-content > .code-block-wrapper:hover::before {
    color: var(--accent-cyan);
}

.main-content > .code-block-wrapper.current-line::before {
    color: #2BB6BA !important;
    font-weight: 700 !important;
    text-shadow: 0 0 8px rgba(43, 182, 186, 0.5) !important;
}

教訓: CSSセレクタは一箇所直したら、関連するすべての箇所を確認せよ

#まとめ

今回の修正で学んだこと。

  1. CSSとJavaScriptで同じ行番号を使うなら、計算方法を一致させる: CSSカウンターの対象とインデックス生成の対象を揃える
  2. テキストマッチングは脆い: 同じテキストが複数箇所にあるとすぐ破綻する
  3. DOM構造を信じる: 行番号 → 要素 → ハイライト、という確実な経路を作る
  4. CSSセレクタは関連箇所をすべて確認: 一箇所直したら、:hover.current-line など関連するすべてのルールも確認

「検索してジャンプ」という一見シンプルな機能でも、行番号の生成、インデックスとの対応、ターゲット特定のロジック、CSSセレクタの一貫性、いろんなところでズレが生じうる。

でもまあ、こういうのを一つずつ潰していくのが楽しいんだよね。

完璧に動いた時の達成感は格別だ。