選択したテキストをハイライト表示する DOM を生成する JavaScript

通常、テキストを選択するとデフォルト機能でハイライト表示されますが、要件によってはそれは使わずにオリジナルの DOM を使って表現したいときがあります。単に見た目を装飾したいだけなら CSS の `::selection` セレクタを使えばできますが DOM にしたいのです。というわけでサンプルソースです。

（一部まだ描画が甘い部分はありますが、ベースこれで行けると思います）

## 動作イメージ

![selection2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2152/bbff42c7-7c73-85ba-f0d7-086f1d1128cb.gif)

このような感じで DIV を3つ用意し、それぞれで選択範囲を表現します。

<img width="700" alt="selection_html.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2152/112b1f74-5e43-4bfc-94e2-7a01592bab38.png">

### サンプルソースコード

とりあえず雑に全ソースを掲載します。サンプルは縦書き用にしていますが、横書きでも座標計算を調整すればできると思います。また、描画のために Proxy を利用していますが、この部分は本質じゃないのであまり気にしないでください。

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
    <style type="text/css">
    body {
      writing-mode: vertical-rl;
    }
    #container {
      position: relative;
      width: 300px;
      height: 200px;
    }
    #editable {
      position: absolute;
      top: 0px;
      left: 0px;
      z-index: 2;
    }
    p::selection {
      background: transparent;
    }
    .selection {
      position: absolute;
      background: #f00;
      opacity: 0.5;
      z-index: 1;
    }
    </style>
  </head>
  <body>
    <div id="container">
      <div id="editable"><p>テキストです。</p><p>改行したりします。</p><p>ここは折り返しするテキストです。ここは折り返しするテキストです。ここは折り返しするテキストです。</p></div>
      <div id="first" class="selection"></div>
      <div id="middle" class="selection"></div>
      <div id="last" class="selection"></div>
    </div>
    <script>
    const selectionDOM = {
      first: document.getElementById('first'),
      middle: document.getElementById('middle'),
      last: document.getElementById('last'),
    }
    const style = {
      first: {
        width: 0,
        height: 0,
        top: 0,
        left: 0
      },
      middle: {
        width: 0,
        height: 0,
        top: 0,
        left: 0
      },
      last: {
        width: 0,
        height: 0,
        top: 0,
        left: 0
      }
    }
    const handler = {
      get: (target, prop) => {
        if (typeof target[prop] === 'object' && target[prop] !== null) {
          target[prop].name = prop
          return new Proxy(target[prop], handler)
        }
        return target[prop]
      },
      set: (target, prop, value) => {
        selectionDOM[target.name].style[prop] = `${value}px`
        target[prop] = value
        return true
      }
    }
    const styleProxy = new Proxy(style, handler)

    const resetMiddleAndLast = () => {
      styleProxy.middle.width = 0
      styleProxy.middle.height = 0
      styleProxy.middle.top = 0
      styleProxy.middle.left = 0
      styleProxy.last.width = 0
      styleProxy.last.height = 0
      styleProxy.last.top = 0
      styleProxy.last.left = 0
    }
    const selection = () => {
      const sel = window.getSelection()
      const range = sel.getRangeAt(0)
      const rect = range.getBoundingClientRect()
      const parentRect = document.getElementById('container').getBoundingClientRect()

      if (styleProxy.first.width === 0) {
        styleProxy.first.width = rect.width
      }

      const textRange = document.createRange()
      if (range.startOffset + 1 <= range.startContainer.textContent.length) {
        textRange.setStart(range.startContainer, range.startOffset)
        textRange.setEnd(range.startContainer, range.startOffset + 1)
        const textRect = textRange.getBoundingClientRect()
        styleProxy.first.top = textRect.top - parentRect.top
        styleProxy.first.left = textRect.left - parentRect.left
      }
      if (range.endOffset > 0) {
        textRange.setStart(range.endContainer, range.endOffset - 1)
        textRange.setEnd(range.endContainer, range.endOffset)
        const textRect = textRange.getBoundingClientRect()
        styleProxy.first.height = textRect.top - parentRect.top - styleProxy.first.top + textRect.height
      }

      if (styleProxy.first.width < rect.width) {
        styleProxy.first.height = rect.height - styleProxy.first.top

        if (range.endOffset > 0) {
          styleProxy.last.width = styleProxy.first.width
          styleProxy.last.left = rect.left - parentRect.left
          styleProxy.last.top = rect.top - parentRect.top
          textRange.setStart(range.endContainer, range.endOffset - 1)
          textRange.setEnd(range.endContainer, range.endOffset)
          const textRect = textRange.getBoundingClientRect()
          styleProxy.last.height = textRect.y - parentRect.top + textRect.height
        }

        styleProxy.middle.width = rect.width - styleProxy.first.width - styleProxy.last.width
        styleProxy.middle.height = rect.height
        styleProxy.middle.top = rect.top - parentRect.top
        styleProxy.middle.left = rect.left - parentRect.left + styleProxy.first.width
      } else {
        resetMiddleAndLast()
      }

      textRange.detach()
    }
    document.addEventListener('selectionchange', selection)
    </script>
  </body>
</html>
```

## 解説

### 選択文字の座標取得

テキストを選択するということは Range オブジェクトが使えます。Range オブジェクトの座標は `getBoundingClientRect` で取得できます。

- 参考: [ContentEditable な caret の位置（座標）を取得する](https://qiita.com/zaru/items/6c6e848ccf74beebcc8d)

```javascript
window.getSelection().getRangeAt(0).getBoundingClientRect()
```

これで行けそうな予感がありますが、複数行またいだ選択をしている場合はだめです。単なる短形になるため選択範囲の最大の大きさになってしまいます。そこで、最初に選択した文字の位置と、最後に選択した文字の位置単位で座標を取得する必要があります。

### 最初に選択した文字の座標

Range オブジェクトまるごとじゃなく TextNode 単位でも座標は取得できます。つまり `createRange` で指定の TextNode の Range オブジェクトを生成してしまえば文字単位での座標が手に入るということになります。

```javascript
const textRange = document.createRange()
textRange.setStart(range.startContainer, range.startOffset)
textRange.setEnd(range.startContainer, range.startOffset + 1)
const textRect = textRange.getBoundingClientRect()
```

この最初の文字選択した範囲を DIV の一つに任せます。

### 最後に選択した文字の座標

同様に最後に選択した文字も取得できます。最後の文字は endContainer の endOffset になるので以下のようになります。

```javascript
const textRange = document.createRange()
textRange.setStart(range.endContainer, range.endOffset - 1)
textRange.setEnd(range.endContainer, range.endOffset)
const textRect = textRange.getBoundingClientRect()
```

あとは最初と最後の間を補完する中間の DOM を作れば完成です。