N 文字以上なら省略表示

「N 文字以上 / 以内」みたいなことを言われたときに考えること.

「文字」とは?

単に「文字」と言っても, それが指しているものが何かは自明ではない.

符号単位 (code unit)

JavaScript の場合, 文字列は UTF-16 としてエンコードされている*1ので, そのエンコードの単位である 16 bit ごとに分割するというのがこの方法. .length で取得できるのはこの符号単位の数で, .slice() に与えるのも符号単位で数えたインデックスとなっている.

> "あいうえおABCDE".length
10
> "あいうえおABCDE".slice(0, 5)
"あいうえお"

ところで Unicode には 16 bit で表せる数以上の「文字」 (U+0000 〜 U+10FFFF) が含まれるので, UTF-16 では 1 つの符号単位で表せない「文字」は 2 つの符号単位 (サロゲートペア) を使って表すことになる. 例えば絵文字 (🍣 = U+1F363 など) や, マイナーめな漢字 (𰻞 = U+30EDE など) が代表例. こういったものが文字列に含まれると, 長さの計算が意図と異なったり, 途中で打ち切った時に文字化けしたような見た目になってしまうことがあるので注意.

> "あいうえお🍣ABCDE".length
12
> "あいうえお🍣ABCDE".slice(0, 6)
"あいうえお\ud83c"

符号単位は長さが決まっているので, データサイズとしての文字列の長さを測る場合にはこれが適している. ただし JavaScript.length で計算できるのはあくまで UTF-16エンコードしたときのサイズであって, UTF-8 などエンコーディングが変わればサイズも変わることに注意. 表示する用途で文字列を区切るのに使うのは微妙.

符号位置 (code point)

Unicode に含まれる「文字」に与えられる符号 U+0000 〜 U+10FFFF のそれぞれは符号位置と呼ばれていて, 上記のようなサロゲートペアに対してはこの単位で区切ることでマシな結果が得られる. JavaScript では文字列のイテレータがこの単位で分割してくれるようになっている.

> [..."あいうえお🍣ABCDE"].length
11
> [..."あいうえお🍣ABCDE"].slice(0, 6).join("")
"あいうえお🍣"

一方でこれにもまだ問題があって, この世界には複数の符号位置を組み合わせて 1 つの「文字」を作るようなものがある. 代表的なものは結合文字 ( = U+0065 U+0301) や国旗の絵文字 (🇯🇵 = U+1F1EF U+1F1F5), その他絵文字のスキントーンや組み合わせなどがある. 結合文字については .normalize() で 1 つの符号位置を使うように正規化できることもあるが, 絵文字などはそうもいかない.

> [..."あいうえお🇯🇵ABCDE"].length
12
> [..."あいうえお🇯🇵ABCDE"].slice(0, 6).join("")
"あいうえお🇯"

Unicode の符号位置の並びを考えたい時は符号位置を使うと良い (それはそう). やはり表示する用途で文字列を区切るのに使うのは微妙.

書記素 (grapheme)

人間が素朴に考える「文字」に最も近いと思われる概念が書記素で, 名前の通りこれ以上分解すると記号の意味が成り立たなくなる単位を指す. JavaScript では Intl.Segmenter を使うとこの単位で文字列を分割できる. 2024 年 4 月に Firefox 125 がリリースされたことで, 主要なブラウザやサーバーサイドのランタイム全てで Intl.Segmenter を使えるようになった (それまではサードパーティのライブラリを使う必要があった).

> const segmenter = new Intl.Segmenter("ja-JP", { granularity: "grapheme" })
> [...segmenter.segment("あいうえお🇯🇵ABCDE")].length
11
> [...segmenter.segment("あいうえお🇯🇵ABCDE")].slice(0, 6).map((x) => x.segment).join("")
"あいうえお🇯🇵"

欠点があるとすれば, 書記素の区切り方は Internationalization API の仕様*2では定められておらず, 環境によって結果が変わり得ることだが, 仕様にも補足があるように大体の実装は Unicode が定めているガイドライン*3に従っているはずで, ほぼほぼ同じ結果が得られるだろう (反例があったら教えてください).

省略表示などで文字列を区切りたいときは, 書記素を使っておけば概ね自然な結果が得られるはず.

ここまで 0.1 秒

他にも, 単に「N 文字」と言った場合, 暗黙の内に「日本語で」など特定の言語が仮定されている可能性がある. 例えば日本語と英語を比較すると, 平均的な文字の幅や, 同じ文字数で表現できる内容 (密度) が異なるので, 日本語の場合は N 文字までのところを, 英語では倍の 2 N 文字までとしたい, などがあるかもしれない.

*1:実際のところ ASCII の範囲だけ扱う時には無駄が多いので, 実体は必ずしもそうなってはいないかもしれないが, 少なくとも表面上は UTF-16 である

*2:https://tc39.es/ecma402/#sec-findboundary

*3:https://unicode.org/reports/tr29/

N 行以上なら省略表示

N 行以上なら省略表示したい

所感: LGTM

import { FC, ReactNode, CSSProperties } from "react";
import styles from "./LineClampingBox.module.scss";

/** 指定した行数以上なら省略表示する */
export const LineClampingBox: FC<{
  /** 最大行数 */
  maxLines: number;
  children: ReactNode;
}> = ({ maxLines, children }) => {
  return (
    <div
      className={styles.box}
      // CSS variables で値を渡す
      style={{ "--maxLines": maxLines } as CSSProperties}
    >
      {children}
    </div>
  );
};
.box {
  // -webkit- prefix を使い続けるのはやや微妙だが, 現状はこれが最適と思われる
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--maxLines);
  overflow: hidden;
}

N 行以上なら省略表示しつつ, 省略され得る時は開閉ボタンを表示したい

所感: 実装を読み書きするコストが必要以上に高いかもです. 本当に「N 行以上なら省略表示」が重要なのであればこれでもよいですが, もし可能なら他の手段も検討できませんか?

import { FC, ReactNode, CSSProperties, useState, useRef, useEffect } from "react";
import clsx from "clsx";
import styles from "./LineClampingBox.module.scss";

/** 指定した行数以上なら省略表示する */
export const LineClampingBox: FC<{
  /** 省略時の最大行数 */
  maxLines: number;
  /**
   * 注意: ステートフルなコンポーネントを children に渡さないこと.
   * LineClampingBox は省略表示するのに十分な行数があるかを判定するために, 二重に children を描画する.
   * もしステートフルなコンポーネントが存在すると状態がずれてしまい, 誤った判定をしてしまう可能性がある.
   * (二重に children を描画するのではなく, 描画された要素をコピーすれば解消するかも?)
   */
  children: ReactNode;
}> = ({ maxLines, children }) => {
  // hasManyLines = 省略表示をするのに十分な行数があるか = 開閉ボタンを表示すべきか.
  // 描画前に計算することは不可能なので, 描画した結果を元に判定する.
  // CSS だけで判定できると JS の実行を待たずに済むので layout shift が減らせるのだが,
  // そういった手段は現状ではなさそうに見える.
  const [hasManyLines, setHasManyLines] = useState(false);
  const hiddenTextRef = useRef<HTMLDivElement | null>(null);
  // 判定は要素がリサイズされたときか children が変化したときに行えば良い.
  // ResizeObserver を使いつつ, children が変化したときは再判定させるために useEffect の依存配列に children を入れている.
  useEffect(() => {
    const elem = hiddenTextRef.current;
    if (!elem) {
      return () => {};
    }
    const observer = new ResizeObserver(() => {
      // 省略表示されているときは scrollHeight > clientHeight になっている
      // 参考: https://stackoverflow.com/questions/52169520/how-can-i-check-whether-line-clamp-is-enabled
      setHasManyLines(elem.scrollHeight > elem.clientHeight);
    });
    observer.observe(elem);
    return () => {
      observer.disconnect();
    };
  }, [children]);

  const [isOpen, setIsOpen] = useState(false);

  return (
    <div
      className={styles.box}
      // CSS variables で値を渡す
      style={{ "--maxLines": maxLines } as CSSProperties}
    >
      <div className={clsx(styles.children, isOpen && styles.open)}>
        {children}
      </div>
      {/*
        * hasManyLines の判定に使う非表示の children.
        * inert, aria-hidden 属性を付けてインタラクションを無効化しておく.
        */}
      <div className={clsx(styles.children, styles.hidden)} inert aria-hidden ref={hiddenTextRef}>
        {children}
      </div>
      {hasManyLines && (
        <div>
          {isOpen ? (
            <button type="button" onClick={() => { setIsOpen(false); }}>
              show less
            </button>
          ) : (
            <button type="button" onClick={() => { setIsOpen(true); }}>
              show more
            </button>
          )}
        </div>
      )}
    </div>
  );
};
.box {
  position: relative;
}

.children {
  // -webkit- prefix を使い続けるのはやや微妙だが, 現状はこれが最適と思われる
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--maxLines);
  overflow: hidden;

  // display: none だとサイズが測れないので, opacity: 0 で透明化する.
  // そのままだと領域をとってしまうので, position: absolute で領域をとらないようにする.
  &.hidden {
    position: absolute;
    opacity: 0;
  }

  &.open {
    -webkit-line-clamp: none;
  }
}

次回: N 文字以上なら省略表示したい

「文字」とは?