6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

その 「...」、JS で制御していますか?Canvas 実測+ResizeObserver のテキスト省略処理

6
Posted at

はじめに

UI を作っていると、

横幅に収まらないテキストを … で省略したい

という場面は頻繁にあります。

CSS には text-overflow: ellipsis; がありますが、次のような要求がでることもあります。

  • 親要素の幅ではなく 任意の px 幅で制御したい
  • フォントによる 実際の描画幅を考慮したい
  • JavaScript から 明示的に省略処理を管理したい

この記事では、

  • Canvas を使った 幅実測ベースのテキスト省略
  • その実装を ResizeObserver で進化させる

という流れで紹介します。

デモイメージ

html
<div class="truncate">短いテキスト</div>
<div class="truncate">
  これはちょっと長めのテキストで省略される可能性があります
</div>
<div class="truncate">
  さらに非常に長いテキストをここに書くとどうなるかテストします
</div>
見た目と1行制御
.truncate {
  display: inline-block;   /* 幅制御のため */
  max-width: 150px;
  white-space: nowrap;
  overflow: hidden;

  border: 1px solid #ccc;
  padding: 4px 8px;
  margin: 4px;
}

※ 今回は CSS の ellipsis は使いません。
省略判定は JavaScriptで行います。

JavaScript:Canvas で文字幅を実測する

基本になる省略関数を作成

/**
 * 幅制限付きテキスト省略関数
 * @param {HTMLElement[]} elements - 省略対象の要素配列
 * @param {number} maxWidth - 最大表示幅(px)
 * @param {string} ellipsis - 省略文字
 */
 
function truncateText(elements, maxWidth = 150, ellipsis = '') {
  var canvas = document.createElement('canvas');
  var context = canvas.getContext('2d');

  for (var i = 0; i < elements.length; i++) {
    var el = elements[i];

    // 再実行時の二重処理防止
    if (el.dataset.truncated === 'true') {
      continue;
    }

    var originalText = el.textContent;

    // 要素の font 情報を Canvas に反映
    var font = window.getComputedStyle(el).font;
    context.font = font;

    // 幅が収まっていれば何もしない
    if (context.measureText(originalText).width <= maxWidth) {
      continue;
    }

    // 末尾を1文字ずつ削る
    var text = originalText;
    while (
      text.length > 0 &&
      context.measureText(text + ellipsis).width > maxWidth
    ) {
      text = text.slice(0, -1);
    }

    el.textContent = text + ellipsis;
    el.title = originalText; // ツールチップで全文表示
    el.dataset.truncated = 'true';
  }
}

初期表示時に省略を実行

window.addEventListener('DOMContentLoaded', () => {
  const targets = document.querySelectorAll('.truncate');
  truncateText(targets, 150);
});

ここまでが、

  • フォント依存の幅を考慮
  • px 単位で正確に省略
  • hover で全文確認可能

という 省略を行う処理になります。

※ html と css(クリックでソースコードを表示)
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>Canvas実測によるテキスト省略デモ</title>
  <style>
    body {
      font-family: sans-serif;
      padding: 24px;
    }

    h1 {
      font-size: 18px;
      margin-bottom: 16px;
    }

    .truncate {
      display: inline-block;   /* 幅制御のため */
      max-width: 160px;
      white-space: nowrap;
      overflow: hidden;

      border: 1px solid #ccc;
      padding: 6px 10px;
      margin: 6px 0;
    }
  </style>
</head>
<body>

  <h1>Canvas 実測によるテキスト省略基本処理</h1>

  <div class="truncate">短いテキスト</div><br>
  <div class="truncate">これはちょっと長めのテキストで省略される可能性があります</div><br>
  <div class="truncate">さらに非常に長いテキストをここに書くとどうなるかテストします</div>

  <script>
    /**
     * 幅制限付きテキスト省略関数
     * Canvas で文字幅を実測し、指定幅を超える場合に ... を付ける
     *
     * @param {HTMLElement[]} elements - 省略対象の要素配列
     * @param {number} maxWidth - 最大表示幅(px)
     * @param {string} ellipsis - 省略文字
     */
    function truncateText(elements, maxWidth, ellipsis) {
      if (maxWidth === undefined) {
        maxWidth = 160;
      }
      if (ellipsis === undefined) {
        ellipsis = '...';
      }

      // 文字幅計測用 Canvas
      var canvas = document.createElement('canvas');
      var context = canvas.getContext('2d');

      for (var i = 0; i < elements.length; i++) {
        var el = elements[i];

        // すでに省略済みの場合は何もしない
        if (el.dataset.truncated === 'true') {
          continue;
        }

        var originalText = el.textContent;

        // 要素のフォント情報を Canvas に反映
        var font = window.getComputedStyle(el).font;
        context.font = font;

        // テキストが収まっている場合は省略しない
        if (context.measureText(originalText).width <= maxWidth) {
          continue;
        }

        // 末尾を1文字ずつ削って幅に収める
        var text = originalText;
        while (
          text.length > 0 &&
          context.measureText(text + ellipsis).width > maxWidth
        ) {
          text = text.slice(0, -1);
        }

        // 省略結果を反映
        el.textContent = text + ellipsis;
        el.title = originalText; // hover で全文確認
        el.dataset.truncated = 'true';
      }
    }

    // ページ読み込み後に実行
    window.addEventListener('DOMContentLoaded', function () {
      var targets = document.querySelectorAll('.truncate');
      truncateText(targets, 160, '...');
    });
  </script>

</body>
</html>

ここまでの実装の弱点

例えば:

  • ウィンドウリサイズ
  • レスポンシブ切り替え
  • サイドバーの開閉
  • 動的にスタイルが変わる UI

この場合、
省略結果がズレたままになる可能性があります。

ResizeObserver で省略処理を自動追従させる

そこで登場するのが ResizeObserver です。

ResizeObserver とは?

  • 要素単位でサイズ変化を監視できる API
  • window.onresize より正確
  • モダンな UI 実装と相性が良い

ResizeObserver は 2019 年前後に仕様ドラフトが公開され、その後 Chrome / Firefox / Safari で実装が揃ってきた API です。
いまどきのブラウザ環境であれば、モダン UI の前提として使える存在になっています。

/**
 * ResizeObserver を使って
 * 要素のサイズ変更に応じてテキスト省略を再計算する
 */
var observer = new ResizeObserver(function (entries) {

  // サイズが変わった要素ごとに処理
  entries.forEach(function (entry) {

    // サイズが変更された要素
    var el = entry.target;

    // 以前の省略結果をそのまま使わないため、
    // 「省略済み」フラグをリセットする
    el.dataset.truncated = 'false';

    // 現在の要素幅(px)を取得し、
    // その幅を上限として省略処理を再実行する
    truncateText(
      [el],                       // 単一要素だが配列として渡す
      entry.contentRect.width     // ResizeObserver が検知した現在の幅
    );
  });
});


/**
 * 省略対象の要素を ResizeObserver に登録する
 * これにより、要素の幅が変わるたびに observer が呼ばれる
 */
var targets = document.querySelectorAll('.truncate');

targets.forEach(function (el) {
  observer.observe(el);
});

処理の流れを整理すると

  1. .truncate クラスを持つ要素を監視対象として登録
  2. 要素の幅が変わる
  3. ResizeObserver が変更を検知
  4. 以前の省略結果を一旦リセット
  5. 現在の幅を使って省略処理をやり直す
  6. 常に正しい省略状態を維持
※ ResizeObserver対応の html と css(クリックでソースコードを表示)
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>ResizeObserver × Canvas 実測テキスト省略</title>
  <style>
    body {
      font-family: sans-serif;
      padding: 24px;
    }

    h1 {
      font-size: 18px;
      margin-bottom: 16px;
    }

    .container {
      resize: horizontal;
      overflow: auto;
      border: 1px dashed #aaa;
      padding: 12px;
      width: 240px;
      margin-bottom: 20px;
    }

    .truncate {
      white-space: nowrap;
      display: block;
      border: 1px solid #ccc;
      padding: 6px 10px;
    }
  </style>
</head>
<body>

  <h1>ResizeObserver × Canvas 実測テキスト省略</h1>

  <p>枠の右下をドラッグして幅を変更してください</p>

  <div class="container">
    <div class="truncate">
      ResizeObserver を使うと要素サイズの変化に応じて自動的にテキスト省略を再計算できます
    </div>
  </div>

  <script>
    /**
     * 幅制限付きテキスト省略関数
     * Canvas で文字幅を実測し、省略記号を付与する
     */
    function truncateText(elements, maxWidth, ellipsis) {
      if (maxWidth === undefined) {
        maxWidth = 150;
      }
      if (ellipsis === undefined) {
        ellipsis = '...';
      }

      var canvas = document.createElement('canvas');
      var context = canvas.getContext('2d');

      for (var i = 0; i < elements.length; i++) {
        var el = elements[i];

        // 元テキストを保持(再計算のため)
        if (!el.dataset.originalText) {
          el.dataset.originalText = el.textContent;
        }

        var originalText = el.dataset.originalText;

        // Canvas にフォント情報を反映
        var font = window.getComputedStyle(el).font;
        context.font = font;

        // いったん元テキストに戻す
        el.textContent = originalText;

        // 収まる場合はそのまま
        if (context.measureText(originalText).width <= maxWidth) {
          return;
        }

        // 末尾を削って省略
        var text = originalText;
        while (
          text.length > 0 &&
          context.measureText(text + ellipsis).width > maxWidth
        ) {
          text = text.slice(0, -1);
        }

        el.textContent = text + ellipsis;
        el.title = originalText;
      }
    }

    /**
     * ResizeObserver
     * 要素のサイズ変更を検知して省略処理を再実行する
     */
    var observer = new ResizeObserver(function (entries) {
      for (var i = 0; i < entries.length; i++) {
        var entry = entries[i];
        var el = entry.target;

        truncateText(
          [el],
          entry.contentRect.width,
          '...'
        );
      }
    });

    // 監視対象を登録
    window.addEventListener('DOMContentLoaded', function () {
      var targets = document.querySelectorAll('.truncate');

      for (var i = 0; i < targets.length; i++) {
        observer.observe(targets[i]);
      }
    });
  </script>

</body>
</html>

何が改善されたのか?

変更前 ResizeObserver 対応後
初期表示のみ サイズ変更のたびに再計算
レイアウト崩れの可能性 常に正しい省略
静的UI向け 動的UI・SPA向け

特に、

  • 管理画面
  • React / Vue / SPA
  • レスポンシブ前提のUI

では 役だつ考え方になります。

まとめ

  • CSS の ellipsis は万能ではない
  • Canvas を使えば 描画幅ベースの正確な省略ができる
  • ResizeObserver を組み合わせることで
  • 動的レイアウトにも強い実装になる

「CSSだけでは足りない一歩先のテキスト制御」として、
覚えておくと役立つパターンです。

6
1
2

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
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?