0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

キャレット余白でレイアウトが崩れた話と、ミラー要素で直した手順メモ

Posted at

IT 初心者が学習記録として残します。textarea の自動リサイズで、1 行のときと 2 行のときで取得される高さが同じになり、2 行入力しても入力欄の高さが 1 行分のまま…という現象にハマりました。最終的に ミラー要素(見えないコピー要素)で安定して解決できたので、原因の理解と実装メモをまとめます。AI(codex)に修正を頼んだときの失敗談も添えます。

どんな症状だったか

  • 入力直後は 1 行分の高さ
  • 2 行目に改行しても高さが変わらない
  • 3 行目以降でやっと伸びる
  • scrollHeight を見て高さを決めているのに、1 行と 2 行で同じ値が返ってくる

再現イメージ:

  • 1 行入力 → 高さ 28px(本来は14px)
  • 2 行入力 → 依然 28px

なぜ起きていたか(自分の理解)

textarea.scrollHeight は「スクロールしないと読めないコンテンツの全高」ですが、次の要因で 最初の改行が高さに反映されにくいことがあります。

  • 1行入力の際はfont-sizeの14oxとキャレット余白の14pxが入り28pxになる
  • 2行入力の際はキャレット余白に2行目が入流ことで、1行と同じ28pxになる

結果として、1 行 → 2 行へ増えた直後でも scrollHeight が増えず、「高さが同じ」に見える状態になっていたと考えています。

先にやって失敗したこと(scrollHeight だけで戦う)

原因を考えず、codexに〇〇な不具合出てるから直して!とお願いしました。
ですがcodexはscrollHeightの値を加工する形で修正しようとしており、3回修正を頼みましたが上手くいかないどころか悪化しました。
1行目と2行目の高さの値が同じなんですから、同じ値をいくら弄っても、1行表示か2行表示のどちらかで必ず不具合が発生します。
そのことをAIに指摘しても意味のない修正を繰り返していましたが、、、

解決策:ミラー要素で「見たまま」を測る

textarea と同じスタイルを持つ **隠し要素(div)**に同じテキストを流し込み、その要素の offsetHeight を読む方法です。表示エンジンが実際に組版した高さをそのままもらえるので、キャレット余白や改行の扱いでズレにくいです。

ポイント:

  • white-space: pre-wrap;word-break: break-word;(または overflow-wrap: anywhere;)を指定
  • textarea同じフォント/行高/letter-spacing/width/padding/border/box-sizing をミラー側にコピー
  • ミラーは visibility: hidden; position: absolute; で画面外または重ねて配置

実装例(最小構成)

HTML

<div class="field">
  <textarea id="memo" rows="1" placeholder="メモを書く…"></textarea>
  <div id="memo-mirror" class="mirror" aria-hidden="true"></div>
</div>

CSS

.field {
  position: relative;
  width: 100%;
  max-width: 520px;
}

#memo {
  width: 100%;
  min-height: 24px;
  resize: none;
  overflow: hidden;        /* スクロールバーを出さない */
  box-sizing: border-box;  /* ミラーと合わせる */
  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
  letter-spacing: 0.02em;
  padding: 8px 10px;
  border: 1px solid #d0d7de;
  border-radius: 8px;
}

.mirror {
  position: absolute;
  top: 0;
  left: -9999px;           /* 画面外へ退避(visibility: hidden でもOK) */
  white-space: pre-wrap;   /* 改行とスペースを表示と同じ扱いに */
  word-break: break-word;  /* 長い単語の折返し */
  overflow-wrap: anywhere;

  /* textarea と完全一致させるプロパティ群 */
  box-sizing: border-box;
  width: 100%;
  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
  letter-spacing: 0.02em;
  padding: 8px 10px;
  border: 1px solid #d0d7de;
  border-radius: 8px;
}

JavaScript

const ta = document.getElementById('memo');
const mirror = document.getElementById('memo-mirror');

const ESCAPE_MAP = {
  '&': '&amp;',
  '<': '&lt;',
  '>': '&gt;'
};

const escapeHtml = (s) => s.replace(/[&<>]/g, ch => ESCAPE_MAP[ch]);

function syncHeight() {
  // 1) textarea の内容を HTML に変換
  //    - 改行は <br> に
  //    - 末尾に &nbsp; を足してキャレット位置の余白を吸収
  const value = ta.value;
  const html = escapeHtml(value).replace(/\n/g, '<br>') + '&nbsp;';
  mirror.innerHTML = html;

  // 2) ミラーの高さを読む(border/padding を含む)
  const h = mirror.offsetHeight;

  // 3) textarea に適用(最小高さは rows=1 相当を維持)
  ta.style.height = 'auto';
  ta.style.height = `${Math.max(h, 24)}px`;
}

// 入力・貼り付け・変換確定のタイミングで同期
['input', 'change', 'keyup'].forEach(ev => ta.addEventListener(ev, syncHeight));
// IME 変換確定でも反映
ta.addEventListener('compositionend', syncHeight);

// 初期化
syncHeight();

なぜこれで直るのか

  • scrollHeight 依存だとscrollHeightがそもそも間違ってたらどうしようもない。
  • ミラーは 実際の描画と同じ規則white-space: pre-wrap 等)で組版するため、1 行 → 2 行 の境目でも忠実に高さが増える
  • キャレットの“見えない幅”は、末尾の &nbsp; が吸収し、測定誤差が出にくい

つまずきポイントとチェックリスト

  • フォント・行高・padding・border・box-sizing を 1 つでも合わせ忘れるとズレます
  • placeholder を使うときは、未入力時の高さを別途決めておく(ミラーに placeholder を入れない)
  • 長い URL などは overflow-wrap: anywhere; が有効
  • 高頻度の input でパフォーマンスが気になる場合は requestAnimationFrame で 1 フレームにまとめる
let raf = 0;
ta.addEventListener('input', () => {
  if (raf) cancelAnimationFrame(raf);
  raf = requestAnimationFrame(syncHeight);
});

codex に頼んでも直らなかった理由(自省)

指示の出し方が悪かったです。やりとりは「scrollHeight の値が合わない → じゃあ +2px しよう」みたいな対症療法のループでした。AI に投げるときは以下を意識すると良かった。

  • AIが解決できそうにない→情報が足りないか、それまでの会話履歴によりscrollHeightなどの構造を維持しようとしている可能性を考える
  • まずは何が原因か自分でしっかり把握する
  • AIは間違った解決策を正しいと思い込んでいるように変な修正を繰り返すので、新しいチャットに移るか、AIの間違っている点を明示的に指摘し、修正方法もこちらで指示を出す

AI は万能ではないので、測り方そのものを変えるという発想のジャンプを、人間側から誘導するのがコツだと学びました。

付録:scrollHeight で起きていた最小失敗例

<textarea id="t" rows="1" style="line-height:1.5; padding:8px 10px; box-sizing:border-box; resize:none; overflow:hidden;"></textarea>
<script>
  const t = document.getElementById('t');
  function resize() {
    t.style.height = 'auto';
    t.style.height = t.scrollHeight + 'px'; // ← 1 行と 2 行で同値になることがある
  }
  t.addEventListener('input', resize);
  resize();
</script>

この方法でも動く環境はありますが、フォントや行高次第でブレやすく、今回のような「2 行目で伸びない」症状に直面しました。

まとめ

  • textarea の自動リサイズで、1 行と 2 行の高さが同じになる現象に遭遇
  • scrollHeight の後処理をいじるだけでは安定しなかった
  • ミラー要素方式に切り替え、pre-wrap と末尾 &nbsp; でキャレット余白も吸収 → 解決
  • AI を使う際は、実装内容を理解することAIの間違いを発見し修正するだけのスキルを身につけることが大切

どなたかのハマりポイント回避の助けになれば幸いです!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?