親要素の幅に合わせて文字幅を自動調整するディレクティブをAngularで作る

おはようございます! こんにちはこんばんは!!

今回は小ネタですが、今までありそうでなかったタイポグラフィのお話をしようと思います。

Excel でセルからはみ出た文字のサイズを自動的に小さくしてくれるアレと同じようなことを Angular アプリケーションで実現するのが今日のテーマです。

f:id:tercel_s:20190713163028p:plain:w350

やりたいこと

Web アプリの右上の方に、ログインユーザ名を表示するエリアが配置されているとしましょう。 ところが、デザイナーさんがそのエリアの幅を 200px 固定とかの仕様にしちゃったせいで、まれに長い名前のユーザがログインするとユーザ名がギリギリ枠に収まりきらないことがあります。

エリアの幅を 1px だけ広げたり、ユーザ名のフォントサイズを 1pt だけ縮めたりといった姑息的処置ではなく、もうちょっとマシな方法で解決したい。

そうだ、枠に収まらない場合だけ、文字の幅が自動的に縮小されればいいのになぁ。

基本方針

下図の「メロスは激怒した」の幅を少しだけ縮小する例を考えてみましょう。

f:id:tercel_s:20190713154212p:plain:w350

まず、コンポーネントの左端を原点にして、 {x}方向にスケール変換を行います。

f:id:tercel_s:20190713154235p:plain:w350

ただし、これだけでは、元のコンポーネントのサイズの分だけ余白が残ってしまい、右側に隣接するコンポーネントとの間に不自然な間隔が生じてしまいます(上図の黄色い網掛け領域)。

そこで、margin-right に負の値を設定することで、不要な余白と相殺すれば完成です。

f:id:tercel_s:20190713154256p:plain:w350

ソリューション: 属性ディレクティブ appWidthConstraint

上記の課題をなんとかして解決するべく、appWidthConstraint というディレクティブを作ってみました。 作り方(ソースコード)を示す前に、このソリューションの適用がどれほど簡単かをひけらかすため、まずは使い方の説明を先にしようと思います。

使い方

こんなふうに、ブロック属性かインライン属性のタグに appWidthConstraint ディレクティブを指定するだけで、そいつは親要素の幅に収まるようになります。たぶん。

<div style="width: 100px;">
  <span appWidthConstraint>前田慶次郎利益</span>
</div>

実行イメージ

親要素の幅が文字の表示幅よりも広い場合は、フォントに何もせず表示します。 一方、文字の表示幅が親要素よりも大きい場合は、はみ出ないように自動調整されます。

以下の図では、視覚的に分かりやすいよう、親要素のボーダーに色を付けています。

f:id:tercel_s:20190713170911p:plain:w150

作り方

まずは、resize-observer-polyfill というライブラリを入れます。 これは、HTML コンポーネントのサイズ変更を検知するためのライブラリです。

もともと、サイズの変更検知については ResizeObserver という API があるものの、2019年6月時点ではまだ ChromeOpera しかサポートしていません。 resize-observer-polyfill は、ResizeObserver を他のブラウザでも利用できるようにするためのポリフィルです。

$ npm install resize-observer-polyfill --save-dev

これを入れることによって、レスポンシブ対応のせいで(自身を拘束している)親要素の幅がいきなり変わってしまったときにも、伸縮率を再計算できるように工夫しています。

ここでディレクティブです。

$ ng g directive WidthConstraint

width-constraint.directive.ts

import { Directive, ElementRef, AfterViewChecked, OnDestroy } from '@angular/core';
import ResizeObserver from 'resize-observer-polyfill';
import { Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';

@Directive({
  selector: '[appWidthConstraint]'
})
export class WidthConstraintDirective implements AfterViewChecked, OnDestroy {

  private hostElement:   any = null;  // 自要素への参照
  private parentElement: any = null;  // 親要素への参照

  private innerHtmlChanged$ = new Subject<string>();

  // 親要素のサイズ変更を検知したら再計算する
  private resizeObserver = new ResizeObserver((entries, observer) => {
    for (const entry of entries) {
      const { width } = entry.contentRect;
      const xPadding = this.getXPadding(entry.target as HTMLElement);

      this.update(width, xPadding);
    }
  });

  constructor(el: ElementRef) {
    this.hostElement   = el.nativeElement;
    this.parentElement = this.hostElement.parentNode;

    this.resizeObserver.observe(this.parentElement);

    this.innerHtmlChanged$
      .pipe(distinctUntilChanged())
      .subscribe(() => {
        const xPadding = this.getXPadding(this.parentElement);
        const width    = this.getConstraintWidth(this.parentElement);
        this.update(width, xPadding);
    });
  }

  // 自身の要素の中身が変更されたら再計算する
  ngAfterViewChecked() {
    this.innerHtmlChanged$.next(this.hostElement.innerHTML);
  }

  // 終了処理
  ngOnDestroy() {
    this.resizeObserver.disconnect();

    this.hostElement   = null;
    this.parentElement = null;
  }

  private update(constraintWidth: number, outerElementXPadding: number) {
    if (!(0 < constraintWidth)) {
      this.hostElement.style.display = 'none';
      return;
    }
    this.resetStyles(this.hostElement);
    const width = this.getConstraintWidth(this.hostElement);
    if (width < constraintWidth) { return; }
    this.updateStyles(this.hostElement, width, constraintWidth, outerElementXPadding);
  }

  private tryParseFloat(property: string): number {
    const ret = parseFloat(property);
    return isNaN(ret) ? 0 : ret;
  }

  private getConstraintWidth(targetElement: HTMLElement): number {
    if (!targetElement) { return 0; }
    const { width, paddingLeft, paddingRight } = window.getComputedStyle(targetElement);
    return this.tryParseFloat(width) -
          (this.tryParseFloat(paddingLeft) + this.tryParseFloat(paddingRight));
  }

  private getXPadding(targetElement: HTMLElement) {
    if (!targetElement) { return 0; }
    const { marginLeft, marginRight, borderLeft, borderRight } = window.getComputedStyle(targetElement);

    return (this.tryParseFloat(marginLeft) + this.tryParseFloat(marginRight)) -
           (this.tryParseFloat(borderLeft) + this.tryParseFloat(borderRight));
  }

  private resetStyles(targetElement: HTMLElement) {
    if (!targetElement) { return; }
    targetElement.style.transform  = '';
    targetElement.style.margin     = '0';
    targetElement.style.padding    = '0';
    targetElement.style.border     = 'none';
    targetElement.style.whiteSpace = 'nowrap';
    targetElement.style.display    = 'inline-block';
  }

  private updateStyles(targetElement: HTMLElement, targetWidth: number, constraintWidth: number, outerElementXPadding: number) {
    if (!targetElement) { return; }
    if (!targetWidth)   { return; }
    targetElement.style.transformOrigin = 'left';
    targetElement.style.transform       = `scaleX(${constraintWidth / targetWidth})`;
    targetElement.style.marginRight     = `${constraintWidth - outerElementXPadding - targetWidth}px`;
  }
}

少々厄介なのが、これまで自明のように使ってきた「」という用語についてです。 HTML 要素は、marginborderpadding といった構成要素のどこまでを含むのかというセンシティブな問題があります。

僕はこのあたりのサイトを参考にしましたが、まぁなんでこんなにも複雑な仕様なのか、その割にいまいち痒いところに手が届かないのはなぜなのか……

コンポーネントサイズを取得するための方法も幾通りかありますが、ここでは Window.getComputedStyle() を利用しました。 MDN(英語版)によるとこういうことらしいです(なぜか日本語版の方の更新が10年以上行われていないので、やむなく英語版を貼りました)。

The Window.getComputedStyle() method returns an object containing the values of all CSS properties of an element, after applying active stylesheets and resolving any basic computation those values may contain.

widthpadding-** などの属性は、ピクセル単位*1に揃えられた使用値used valueになります。 autorem30%といった相対表現ではなく、px という厳然とした絶対表現になるところがポイントです。

実際に使用値を取得してみると 287.5px のような string 型の値が返ってきます。 ここから邪魔な px という接尾辞を除去してうまいこと number 型にキャストするため、JavaScript には parseFloat() という実に便利な関数があります。

そんなこんなで最後グダグダになりましたが、先ほどのディレクティブについては、「とりあえず実用上は問題ない(と思われる)レベル」まで調整したつもりです。

制限事項

今回のハックには、以下の制限事項があります。

  • 対象の要素は、自動的に display: inline-block になります
  • アラビア語ヘブライ語のように、右から左に向かって書く言語には対応していません

では、また。

*1:MDNの『使用値』の項より「長さ (例えば width, line-height) の使用値はピクセル数です。」

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.