floatさせた画像をボックスの左下・右下角に配置して、回り込んだテキストと画像を下揃えしたい

画像をボックスの左下や右下角に配置したレイアウトで、回り込んだテキストと画像を下揃えして表示したい。レスポンシブで！ その実現方法のメモです。
![demo.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/2a80b9a7-ace4-fc67-307f-6db4b772c553.jpeg)
※素材画像: Photo by [Chris Lawton](https://unsplash.com/@chrislawton?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 


画像をfloatにして、テキストと下揃えする位置まで動かせば実現できそうです。
課題は、**下揃えになる位置をどのようにして取得するか？**です。ブラウザで表示してテキストの折り返しの状態を見ながら画像の位置を手作業で調整することによって画像とテキストを下揃えにする位置を見つけることはできますが、これではレスポンシブに対応できません。

そこで、JavaScriptの[Intersection Observer API](https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API)を使って、下揃えの調整を自動的に行う方法を考えました。

完成したコードはこちら： [https://codepen.io/kaz_hashimoto/pen/xxPwzZN](https://codepen.io/kaz_hashimoto/pen/xxPwzZN)
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/93f35f42-a6b5-9a5f-53fc-d9d340d292ce.gif)

**動作を確認した環境**（記事執筆時点）

- Chrome 97, Firefox 96, Opera 78, Safari 15.2, Edge 97
- IE11 ただしpolyfill使用: [IntersectionObserver polyfill](https://github.com/w3c/IntersectionObserver/tree/main/polyfill)

## 実現方法
### HTMLとCSS
元々のアイデアは、こちらのブログ記事に紹介されている方法（ボックスの幅は固定）を参考にさせていただきました。

[資料1] [CSSで左下・右下にある画像を回り込ませたい！](https://www.tam-tam.co.jp/tipsnote/html_css/post13870.html)

div.photoの前に「つっぱり棒」の役割をするdv.spaceをfloatで配置してテキストを回り込ませ、.spaceのheightを調整することにより.photoを右下（左下）に配置するという基本的な仕組みは、上記ブログ記載の手法と同じです。

![fig1_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/54905372-938f-683c-b38d-6a21a17dd901.png)

[資料1]の方式と違う点は、**div.boxでfloatの解除をすぐにしない**点です。これは、Intersection Observerにdiv.boxとdiv.photoの交差を検出させるためです。代わりにfloatの解除は.boxの内側と外側の２箇所で行います。

- div.clear-after: .boxの内側。最初は`display: none`にしておき、下揃えの自動調整が完了後`display: block`に切り替えてfloatを解除します。これにより.photoが下側にはみ出した部分が解消されます。
- div.clear: .boxの外側。.boxに続く要素がある場合にレイアウト崩れを防止するため

```html:html
<div class="box">
  <div class="space"></div>
  <div class="photo"><img src="image.png" width="150" height="150" alt=""></div>
  <p>テキスト</p>
  <div class="clear-after"></div>
</div>
<div class="clear"></div>
```

注意点として、ページの最初のロード時にもdiv.boxとdiv.photoの交差を正しく検出させるために、img要素に`width`, `height`属性を指定して画像のアスペクト比をブラウザに認識させる必要があります[^1]。

[^1]: IE11の場合、これだけでは足りず、loadイベントも捕捉して交差を再計算させました（サンプルのコード参照）

```css:css
.box {
  border: 1px solid blue;
}
.clear-after {
  display: none; /* 交差を検出するため最初はfloat解除しない */
  clear: both;
  height: 0;
}
.clear {
  clear: both; /* 後続要素のレイアウト崩れを防止する */
}
.space {
  float: right; /* 画像を左下に配置する場合は値をleftにする */
  height: 5em; /* 仮の高さ */
  outline: 1px solid red;
}
.photo {
  width: 150px;
  float: right; /* 画像を左下に配置する場合は値をleftにする */
  clear: both;
  margin-left: 10px;
}
.photo img {
  width: 100%;
  height: auto;
  vertical-align: top;
}
```

div.spaceの`height`には仮の高さ（ここでは5em）を与えておき、div.photoとそのコンテナのdiv.boxとの位置関係（下図A, B）によって`height`値を1pxずつ加算・減算することにより、div.photoの最適な縦位置になるまで調整していきます。
![fig2_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/d1a6725d-7a02-fa5e-eb8d-22eb256870b3.png)
ここで厄介なのは、div.photoが完全にコンテナの内側に入っている状態で（下図C）div.spaceの`height`を1pxだけ大きくした時、div.photoの上側に開いた空きにテキストがもう１行引き込まれた結果、テキスト最後の行が上にせり上がってdiv.photoがdiv.boxの下側境界を大きくはみ出してしまうケースです（図D）。
![fig3_edit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469745/c1b88929-9f02-4971-4a1f-fcd02f8dd128.png)
この場合は、div.clear-afterを有効にしてfloatを解除することにより、div.boxがdiv.photoを含んだ高さになるようにdiv.boxの下側境界が調整されるので、はみ出し部分が解消されます。ぴったりテキストの下揃えにはなりませんが、Cの状態よりは見た目がマシでしょう。ただし、div.boxの幅が狭い場合、CもDもdiv.boxとテキスト下側境界との間が大きく開いてしまうことがあります。これはどうしようもないので、その場合もDの状態でfloat解除します。

### JavaScript
div.boxを「交差ルート」、div.box > div.photoを交差の監視対象「ターゲット」としてオブザーバーを作成します。本記事の[サンプルのコード](https://codepen.io/kaz_hashimoto/pen/xxPwzZN)では１ページに4個のdiv.boxがあるので、それぞれのboxを指すセレクタとオブザーバーのペアをMapオブジェクトに入れて管理します。
`initObservers()`はウィンドウのリサイズ時にも呼ばれます。`resize`イベントハンドラーから呼ばれた場合、既に動作中のオブザーバーにはターゲットの監視を停止させ、新たにオブザーバーを再作成して監視を再開します。

```javascript:javascript
const observers = new Map(); // セレクタとオブザーバーのペアを記憶しておくMap
const boxes = ['.b1', '.b2', '.b3', '.b4']; // 適用する対象のboxのセレクタ
initObservers(boxes); // boxごとにオブザーバーを作成する

function initObservers(selectors) {
  selectors.forEach(function(s) {
    let obs = observers.get(s); // セレクタに対するオブザーバーを取り出す
    if (obs) {
      obs.disconnect();  // ターゲットの監視を停止
      observers.delete(s); // マップから削除
    }
    obs = createObserver(s); // オブザーバを(再)作成
    observers.set(s, obs);  // セレクタとオブザーバーのペアを記憶
  });
}
```

交差を検出させるタイミングは次の２箇所で、コールバック`handleIntersect()`が呼ばれます。

1. div.photoが1pxでもdiv.boxに交差した時
1. div.photoが完全にdiv.boxに収まった時

```javascript:javascript
function createObserver(selector) {
  const options = {
    root: document.querySelector(selector), // 交差ルート(box)
    rootMargin: '0px',
    threshold: [0, 1] // ターゲットが1pxでも交差した時と完全にboxに入った時にコールバックを実行
  };

  const obs = new IntersectionObserver(handleIntersect, options);
  const target = options.root.querySelector('.photo');
  obs.observe(target); // このbox内のdiv.photoを監視対象とする
  return obs;
}
```

コールバックの処理では、div.photoが1pxでもdiv.boxに交差してた場合は、ターゲットの監視を停止し縦位置の調整を開始します。
div.photoが全く交差していないケース（つまりdiv.boxの下側境界より下に配置されていた場合）では、一旦div.photoをdiv.boxの上辺に移動します。これにより交差が検出されるので、コールバックが再び呼ばれて今度は`entry.isIntersecting`が`true`になり、縦位置の調整処理が実行されます。

```javascript:javascript
function handleIntersect(entries, observer) {
  entries.forEach(function(entry) {
    if (entry.isIntersecting) { // 画像の一部または全部がboxに交差している
      observer.unobserve(entry.target); // このターゲットの監視を停止
      process(observer.root, entry.target); // 画像の縦位置の調整を開始
    } else { // 画像がboxに全く交差していない
      adjustHeight(observer.root, 0, 0); // 画像をboxの上辺に移動し交差を発生させる
    }
  });
}
```

交差を検出したdiv.photoについて、交差の方向（前述の図A or B）に従ってdiv.spaceの`height`を1pxずつ動かして最適な位置を探します。1pxを動かす前後でdiv.boxの下側境界を跨いだらループを抜けます。
処理の最後に`clearfix()`を呼んでfloatを解除します。

```javascript:javascript
function process(root, target) {
  let over = difference(root, target); // 画像とboxの下辺のずれの長さ(px)を取得
  if (over > 0) { // 画像がboxの下側にはみ出している
    do {
      if (!adjustHeight(root, 1)) { // 画像を1px上に戻す
        break; // boxの上辺に達したのでループを抜ける
      }
      over = difference(root, target); // ずれの大きさを測る
    } while (over > 0); // まだはみ出している
    if (over < 0) { // 画像がboxの内側に戻り過ぎた
      adjustHeight(root, -1); // 画像を1px下に戻す（はみ出した状態にする）
    }
  } else if (over < 0) { // 画像がboxの内側に完全に収まっていて下に隙間がある
    do {
      adjustHeight(root, -1); // 画像を1px下げる
      over = difference(root, target); // ずれの大きさを測る
    } while (over < 0); // まだ下に隙間がある
  }
  clearfix(root); // boxのfloatを解除する（はみ出していたら帳尻を合わせる）
}
```

（以下の関数の説明はコードのコメント参照）

<details><summary>difference(root, target)</summary><div>

```javascript:javascript
// 画像(target)がbox(root)に対して「下側にはみ出している長さ」(px)を返す。
// 画像がboxに完全に収まっている場合、戻り値は負または0で、その絶対値は
// 画像の下側とboxの下辺との隙間の長さ(px)を表す。
function difference(root, target) {
  const rb = root.getBoundingClientRect();
  const rt = target.getBoundingClientRect();
  return rt.bottom - rb.bottom;
}
```
</div></details>

<details><summary>clearfix(root)</summary><div>

```javascript:javascript
// boxのfloatを解除する
function clearfix(root) {
  const o = root.querySelector('.clear-after');
  o.style.display = 'block';
}
```
</div></details>

<details><summary>adjustHeight(root, disp, force)</summary><div>

```javascript:javascript
// box(root)に対してspaceの高さをdisp(px)分更新する。
// dispが正の場合、spaceのheightは小さくなり、画像が上へ移動する。
// 画像が上辺に達してdisp分移動できない時、heightを0に設定してfalseを返す。
// dispが負の場合、spaceのheightは大きくなり、画像が下へ移動する。
// forceに値が指定された場合、spaceのheightの値をforceに設定する。
//
function adjustHeight(root, disp, force) {
  const sp = root.querySelector('.space');
  let height;
  let status = true;
  
  if (force !== undefined) {
    height = force;
  } else {
    const style = window.getComputedStyle(sp);
    height = parseFloat(style.height);
    height -= disp;
    if (height < 0) {
      height = 0;
      status = false;
    }
  }
  height = Math.floor(height);
  sp.style.height = height + 'px';
  return status;
}
```
</div></details>

<details><summary>load/resizeイベントのハンドラ</summary><div>

```javascript:javascript
let timer = 0;

// IE11用。画像のロードが終わってからも計算し直す
window.addEventListener('load', function() {
  layout();
});

window.addEventListener('resize', function() {
  if (timer) { // 計算の負荷が高いためlayout()の呼び出しを100msごとに間引く
    return;
  }
  timer = setTimeout(layout, 100);
});

function layout() {
  if (timer) {
    clearTimeout(timer);
    timer = 0;
  }
  removeClearFix(boxes); // boxのfloat解除を無効に戻す
  initObservers(boxes); // オブザーバを再設定する
}

// div.clear-afterに設定したfloat解除を無効にする
function removeClearFix(boxes) {
  boxes.forEach(function(sel) {
    const root = document.querySelector(sel);
    const o = root.querySelector('.clear-after');
    o.style.display = 'none';
  });
}
```
</div></details>

## 残された課題

1. div.photoの横のスペースは、テキストの`min-content`幅が最低でも必要（下記の式）。十分なスペースが確保できない場合、折り返されたテキストとの下揃えはできない。画像がテキストの最終行より下に配置される。例）テキストが長い英単語を含む場合
 - div.boxの幅 > テキストのmin-content幅 ＋ 画像の幅
1. ブラウザの文字サイズのみ拡大した時、レイアウトが追随しない。ページを再読み込みすれば反映される。