Vue.js×TypeScriptでのテキストコピー(iOS対応)

Vue.js×TypeScriptで「ワンタップでテキストをコピーする」ボタンを作ったらハマりポイントがたくさんありました。
生jsやjQueryでの解決策はたくさん見つかりましたが、Vue.js×TSは見つからなかったのでメモです。

# やりたいこと

フォームに文字入力した時に、飾り文字を追加した文章を出力して、ワンタップでコピーできるようにする。

![recipe formatter qiita.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267607/f93adf2c-d010-4c90-5492-1f7cc419cbc9.gif)

# 完成したコード
```vue

    <template lang="pug">
      .CopyText
        button(@click.prevent="copyTexts")
          span.copy-message クリップボードにコピー
        .formatted-text
          span.recipe
            span#copy-text {{formattedTitle}}<br>
    </template>
    <script lang="ts">
    import { Recipe } from "../../components/molecules/RecipeTitle.vue";
    import Vue, { PropType } from "vue";
    export default Vue.extend({
      props: {
        recipe: {
          type: Object as PropType<Recipe>,
          default: {}
        }
      },
      computed: {
        //inputで入力した内容ではなく、ここでフォーマットしたテキストがコピー対象
        formattedTitle(): string {
          return this.recipe.title ? `【${this.recipe.title}】` : "";
        }
      },
      methods: {
        //iOSの判定
        isIOS() {
          const agent = window.navigator.userAgent;
          return agent.indexOf("iPhone") != -1 || agent.indexOf("iPad") != -1;
        },
        //コピー
        copyTexts(): void {
          if (this.isIOS()) {
            //iOSの場合
            const doc: HTMLInputElement = document.getElementById(
              "copy-text"
            ) as HTMLInputElement;
            const selected = window.getSelection();
            const range = document.createRange();
            range.selectNodeContents(doc);
            selected!.removeAllRanges();
            selected!.addRange(range);
            document.execCommand("copy");
          } else {
            //それ以外
            const formattedText = `${this.formattedTitle}`;
            navigator.clipboard.writeText(formattedText);
          }
        }
      }
    });
    </script>
```
参考：[Javascriptによるコピー機能（クロスブラウザ対応）](https://mamewaza.com/support/blog/javascript-copy.html)

# ハマった部分の解説
## iOSでのコピー

jsでコピーをしようと思ったら[navigator.clipboard](https://developer.mozilla.org/ja/docs/Web/API/Navigator/clipboard)を使用するのが一般的かと思います。

ユーザーエージェントなどの情報を扱うNavigatorインターフェイスにclipboardプロパティを追加して、writeText()メソッドを呼び出すことで、テキストがコピーできます。

```js
    navigator.clipboard.writeText(text);
```

しかし、この[navigator.clipboard](https://developer.mozilla.org/ja/docs/Web/API/Navigator/clipboard)はiOSの10以降、textareaなど一部のタグからしかコピーできないなど仕様が変わっています。

今回はinputに入力した文字ではなく、フォーマットをかけたテキストをコピーするため、まさにこの条件に引っかかり、iOSのsafariとchromeで動作しませんでした。

参考: [Copy to clipboard using Javascript in iOS](https://stackoverflow.com/questions/34045777/copy-to-clipboard-using-javascript-in-ios)

そのため、iOSとそれ以外でコピーの処理を変える必要があります。

### iOSかどうかの判定

```jsonnet

  isIOS() {
    const agent = window.navigator.userAgent;
    return agent.indexOf("iPhone") != -1 || agent.indexOf("iPad") != -1 || agent.indexOf("iPod") != -1;
        }
```

[navigator.userAgent](https://developer.mozilla.org/ja/docs/Web/API/NavigatorID/userAgent)を使います。

今回はブラウザではなくiOSかどうかだけ判定するので、上記のようにしてみました。

## iOS用のコピー
iOSのコピーは、コピーしたい文章を選択→コピーの実行という流れで行います。

```js

 const doc = document.getElementById("copy-text");
 const selected = window.getSelection();

 const range = document.createRange();
 range.selectNodeContents(doc);

 selected.removeAllRanges();
 selected.addRange(range);

 document.execCommand("copy");

```

ユーザーはワンタップするだけですが、内部の動作はマウスなどで文章選択→コピーをするのと同じです。

2行目のwindow.getSelectionは[selection](https://developer.mozilla.org/ja/docs/Web/API/Selection)オブジェクトを取得するものです。

selectionオブジェクトは、ユーザーが選択した範囲のDOMに関する情報を持つことができます。

3行目のcreateRangeはdocument中のテキストやノードに関する情報を持つrangeオブジェクトを作成します。

rangeオブジェクトを作成しただけでは何も情報を持っていないため、4行目のrange.selectNodeContents(doc)で、最初に取得した要素を渡します。

5行目は2行目に取得したselectionオブジェクトが現在持っているrangeに関する情報をあらかじめ削除する処理です。文章がすでに選択されてselectionオブジェクトに情報が設定されている場合、この後の処理が無視されるので先に削除してしまいます。

これにより、6行目でselectionオブジェクトに作成したrangeオブジェクトを追加することができます。


最後の[document.execCommand()](https://developer.mozilla.org/ja/docs/Web/API/Document/execCommand)はhtmlのdocumentオブジェクトを操作するコマンドを実行します。copyは選択範囲をクリップボードにコピーするコマンドです。

これでiOSでもテキストコピーができるようになりました！

参考: [memo: テキスト全選択の JavaScript コードが動かなくなったので修正した](https://qiita.com/bass_ikeda/items/a26992f0882fafb06456) 

## TSで"Argument of type 'HTMLElement | null' is not assignable to parameter of type 'Node'"

上記のコードはType Scriptを使うと以下の部分でエラーを吐きます。

```js

  //Argument of type 'HTMLElement | null' is not assignable to parameter of type 'Node'
  const doc = document.getElementById("copy-text");
```
    

document.getElementById() はHTMLElement型もしくはnullを返しますが、nullを返す可能性があるとTSがエラーを出すようです。

そのため、返り値がHTMLElement型であることを明示的に示します。

```js
  const doc: HTMLInputElement = document.getElementById("copy-text") as HTMLInputElement;
```

参考: [Typescript で TS2322 の対処方法](https://usefuledge.com/ts-htmlinput-element-error.html)

## TSでObject is possibly 'null'エラー

rangeの削除、追加部分でも型エラーが出ます。

```js
  selected.removeAllRanges(); //Object is possibly 'null'
  selected.addRange(range); //Object is possibly 'null'
```

これはselectedの部分がnullの可能性があることで出るエラーです。

そこで、!をつけて、selectedがnullでもundefinedでもないことを推論させます。ただ、この方法はESlintで"Forbidden non-null assertion"の警告が出ます。

参考: [非nullアサーション演算子(Non-Null Assertion Operator)](非nullアサーション演算子(Non-Null Assertion Operator))