Elmの中からビデオキャプチャするためのCustom Elementsを作って見る

  ウェブカメラの画像をキャプチャして保存するコンポーネントを作ってみます。顧客登録の際に身分証の画像をキャプチャして一緒に保存するユースケースやQRコードの読み取りを想定しています。

ElmでJavascriptとのInteropというとPortsの利用が推奨されていますが、メジャーなウェブブラウザでもサポートされるようになってきた[Custom Elements](https://developer.mozilla.org/ja/docs/Web/Web_Components/Custom_Elements)で実装してみました。

`create-elm-app`を使用してアプリの雛形を作ります

```shell-session
create-elm-app elm-image-capture-example
cd elm-image-capture-example
```

一部のブラウザに対応するためにwebcomponents関係のpolyfillをインストールします

```shell-session
elm-app eject
npm i @webcomponents/webcomponentsjs
```

`src/index.js`では、先のpolyfillを読み込むと同時に、今回作成したCustom Elementsを読み込み、初期化するように変更します。[ellie-app](https://github.com/ellie-app/ellie)の流儀に従ったのでappを引数として渡していますが、実際には使用はしていません。

```javascript:src/index.js
import './main.css';
import { Main } from './Main.elm';
import registerServiceWorker from './registerServiceWorker';
import '@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js';
import '@webcomponents/webcomponentsjs/webcomponents-lite.js';
import ImageCapture from 'src/ImageCapture.js';

const app = Main.embed(document.getElementById('root'));

registerServiceWorker();

ImageCapture.start(app);
```

`ImageCapture`の定義は巷に出回っているWebRTCを利用したコードを参考にHTML Elementを継承したクラスとして定義します。CustomEventを生成して値を渡す際には`detail`キーに対してデータを渡す必要があります。`event.target.value`の様な形式ではうまく渡せないようです。

```javascript:src/ImageCapture.js
class ImageCapture extends HTMLElement {
  constructor() {
    super()
    this._onButtonClick = this._onButtonClick.bind(this)
  }

  connectedCallback() {
    this._video = this.querySelector('#image-capture-video')
    this._button = this.querySelector('#image-capture-button')

    this._video.addEventListener('click', this._onButtonClick)
    this._button.addEventListener('click', this._onButtonClick)

    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
      navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 960 }})
      .then(stream => {
        this._video.src = window.URL.createObjectURL(stream)
        this._video.play()
      })
      .catch(err => {
        console.log(err);
      })
    }
  }

  _onButtonClick() {
    const image = this._video
    const canvas = document.createElement('canvas')
    canvas.width = 640
    canvas.height = 480
    const ctx = canvas.getContext('2d')
    ctx.drawImage(image, 0, 0, 1280, 960, 0, 0, 640, 480)
    const event = new CustomEvent('capture', { detail: canvas.toDataURL('image/jpeg') })
    this.dispatchEvent(event)
  }
}

export default {
  start(app) {
    customElements.define('image-capture', ImageCapture)
  }
}
```

Elmから呼び出す際は通常のHTML要素と同じように書くことが出来ます。

```elm:Main.elm
module Main exposing (main)

import Html exposing (Html, button, div, img, node, p, text, video)
import Html.Attributes exposing (id, src, width)
import Html.Events exposing (on)
import Json.Decode as Decode


type alias Model =
    { image : Maybe String }


init : ( Model, Cmd Msg )
init =
    ( Model Nothing, Cmd.none )


type Msg
    = OnCapture String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        OnCapture image ->
            { model | image = Just image } ! []


view : Model -> Html Msg
view model =
    div []
        [ node "image-capture"
            [ on "capture" <|
                Decode.map OnCapture <|
                    Decode.field "detail" Decode.string
            ]
            [ video
                [ id "image-capture-video"
                , width 640
                ]
                []
            , p [] [ button [ id "image-capture-button" ] [ text "撮影" ] ]
            ]
        , case model.image of
            Just image ->
                img [ src image, width 640 ] []

            Nothing ->
                text ""
        ]


main : Program Never Model Msg
main =
    Html.program
        { init = init
        , view = view
        , update = update
        , subscriptions = \_ -> Sub.none
        }
```

実際のサンプル

https://ellie-app.com/sjJ2d42y4a1/1

- ポリフィルを組み込んでいないため一部のブラウザでは動かないかもしれません（Google Chrome 66で動作しました）。
- USBウェブカメラが必要です…

Portsを使った場合、JavaScript側とのやり取りのためには、送りと受け取りのために2つのport関数を定義する必要があります。送る際にはonClick発せられたMsgをupdateでport経由でCmd Msgに変換してportとして定義した関数を呼び出します。また、受取の際にはsubscriptionsでport関数から受け取った値をMsgとして発行しupdateで処理して完了します。

一方、Custom Elementsを使用した場合には、JavaScript側へ送る際は要素の属性として渡し（今回は使っていませんが）、受け取る際には通常のイベントと同様に処理できますので、declarativeな感じでとてもきれいに書くことができました。