
検索機能のスクロール精度を改善した話
- 日本語
- Rust
- JavaScript
#はじめに
検索機能、便利だよね。
このサイトには snacks.nvim 風の検索UIがある。/ キーを押すとモーダルが開いて、キーワードを入力すると記事一覧から該当箇所を探してくれる。選択してEnterを押せば、その記事のその行にジャンプ。完璧だ。
…と思っていた。
本番環境にデプロイしたら、全然違う場所にスクロールする。
83行目のキーワードを選んだのに、49行目の同じキーワードで止まる。なんなら正しく動く時もある。不安定。気まぐれ。まるで猫のようだ。
これはその修正の記録である。
#問題その1: スマホで見出しの左に謎の空白
まず気づいたのは、スマホサイズで見出しの左に謎の空白が発生していることだった。
パソコンでは問題ない。スマホでだけ発生する。デベロッパーツールでレスポンシブモードにして確認すると、確かに見出しの左に余計な padding-left がある。
原因は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)でも見出しに余分なパディングが適用されていた。
修正は簡単だった。
/* モバイル用(修正後) */ .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 など)をカウントして、疑似要素で表示する仕組みだ。
.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から抽出したプレーンテキストを行で分割して生成していた。
// 旧実装(問題のコード) 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 という新しい構造を導入した。
/// 検索用のブロック要素(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)ごとにテキストを収集し、行番号を割り当てる。
// ブロック要素の開始・終了をトラッキング 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要素があったことだ。
<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を加算する。
// オフセット: 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行目の同じキーワードにスクロールしてしまう。
原因は、ハイライト処理のターゲット特定ロジックにあった。
旧実装では、テキストマッチングでターゲットを探していた。
// 旧実装(問題のコード) const isTextMatch = targetLineText && normalizedNodeText.includes(normalizedTarget.substring(0, 30));
検索結果から渡された lineText(選択した行のテキスト)の最初の30文字が含まれるノードを探す。見つかったらそこがターゲット。
問題は、同じキーワードが複数箇所にある場合、最初に見つかったノードがターゲットになってしまうことだ。「Rust」というキーワードが記事内に50回出てきたら、どれを選んでも最初の「Rust」にスクロールしてしまう。
#最終解決: lineNum でDOMを直接特定する
テキストマッチングに頼るのをやめた。代わりに、lineNum を使ってDOM上の対応する要素を直接特定する。
// 新実装 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つ。
- CSSカウンター対象と同じセレクタでブロック要素を取得:
:scope > h1, :scope > h2, ...でmain-content直下の要素を配列として取得 - lineNum で要素を直接特定:
blockElements[targetLineNum - 1]で行番号に対応する要素を取得 - contains() でターゲット判定:
targetElement.contains(node)でテキストノードがターゲット要素内にあるかを判定
これにより、同じキーワードが100箇所あっても、選択した行番号のDOM要素内のハイライトに正確にスクロールするようになった。
#問題その5: コードブロックラッパーの存在
「よし、これで完璧だ」と思った翌日。またズレが発覚した。
45行目のキーワードを選んだのに、37行目にスクロールする。8行のズレ。記事内のコードブロックの数と一致している。怪しい。
原因を調べると、コードブロックのHTML構造に問題があった。
<!-- 実際の構造 --> <div class="code-block-wrapper"> <div class="code-block-header">...</div> <pre><code>...</code></pre> </div>
このサイトでは、コードブロックにヘッダーバー(言語名とコピーボタン)を付けている。そのため、<pre> タグは .code-block-wrapper でラップされ、main-content の直接の子要素ではなくなっていた。
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ルールを見ると…
/* :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 を含めることで、ようやく完全に一致した。
/* 修正後: すべての関連ルールに .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セレクタは一箇所直したら、関連するすべての箇所を確認せよ。
#まとめ
今回の修正で学んだこと。
- CSSとJavaScriptで同じ行番号を使うなら、計算方法を一致させる: CSSカウンターの対象とインデックス生成の対象を揃える
- テキストマッチングは脆い: 同じテキストが複数箇所にあるとすぐ破綻する
- DOM構造を信じる: 行番号 → 要素 → ハイライト、という確実な経路を作る
- CSSセレクタは関連箇所をすべて確認: 一箇所直したら、
:hover、.current-lineなど関連するすべてのルールも確認
「検索してジャンプ」という一見シンプルな機能でも、行番号の生成、インデックスとの対応、ターゲット特定のロジック、CSSセレクタの一貫性、いろんなところでズレが生じうる。
でもまあ、こういうのを一つずつ潰していくのが楽しいんだよね。
完璧に動いた時の達成感は格別だ。