ウェブカメラの画像をキャプチャして保存するコンポーネントを作ってみます。顧客登録の際に身分証の画像をキャプチャして一緒に保存するユースケースやQRコードの読み取りを想定しています。
ElmでJavascriptとのInteropというとPortsの利用が推奨されていますが、メジャーなウェブブラウザでもサポートされるようになってきたCustom Elementsで実装してみました。
create-elm-app
を使用してアプリの雛形を作ります
create-elm-app elm-image-capture-example
cd elm-image-capture-example
一部のブラウザに対応するためにwebcomponents関係のpolyfillをインストールします
elm-app eject
npm i @webcomponents/webcomponentsjs
src/index.js
では、先のpolyfillを読み込むと同時に、今回作成したCustom Elementsを読み込み、初期化するように変更します。ellie-appの流儀に従ったのでappを引数として渡していますが、実際には使用はしていません。
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
の様な形式ではうまく渡せないようです。
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要素と同じように書くことが出来ます。
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
}
実際のサンプル
- ポリフィルを組み込んでいないため一部のブラウザでは動かないかもしれません(Google Chrome 66で動作しました)。
- USBウェブカメラが必要です…
Portsを使った場合、JavaScript側とのやり取りのためには、送りと受け取りのために2つのport関数を定義する必要があります。送る際にはonClick発せられたMsgをupdateでport経由でCmd Msgに変換してportとして定義した関数を呼び出します。また、受取の際にはsubscriptionsでport関数から受け取った値をMsgとして発行しupdateで処理して完了します。
一方、Custom Elementsを使用した場合には、JavaScript側へ送る際は要素の属性として渡し(今回は使っていませんが)、受け取る際には通常のイベントと同様に処理できますので、declarativeな感じでとてもきれいに書くことができました。