これはElm 2 Advent Calendar 2017の23日目の記事です。
昨日はpastelIncさんの「Elmでもテストを書こう」でした。
以前、HTML5 File API を使用してドラッグ&ドロップでファイルをアップロードする処理を書いたことがあり、
Elmで再現するのにはどうすればいいんだろう、と試そうとしたのが出発点。
が、現状Elmではサポートしておらず、JavaScriptとの連携が必要になったのでそれで実現してみた、というお話。
参考: HTML5 Drag and Drop in Elm

CoffeeScript版

以前CoffeeScriptで書いたのはこのようなコード(一部抜粋・改変)。
  onDragOver = (f = ()->) ->
    (event) ->
      event.preventDefault()
      return

  onDrop = (f = ()->) ->
    (event) ->
      e = event
      if event.originalEvent
        e = event.originalEvent
      e.preventDefault()
      files = e.dataTransfer.files
      if files.length == 1
        # 実際には一旦 「files[0].name + 'をアップロードします。'」を表示したモーダルを表示し、
        # 「はい」をクリックすると以下のpostを行うようにしていた。
        formData = new FormData
        formData.append 'file', files[0]
        $.post
          dataType: 'text'
          contentType: false
          processData: false
          url: '/file'
          data: formData
          success: (data) ->
            id = JSON.parse(data).id
            location.href = '/file/' + id
            return

  $('#drag-drop-area').ondrop = onDrop()
  $('#drag-drop-area').on 'drop', onDrop()
  $('#drag-drop-area').ondragover = onDragOver()
  $('#drag-drop-area').on 'dragover', onDragOver()
ブラウザがそのファイルを開いてしまわないようにevent.preventDefault()を呼ぶ。
event.originalEventを取り出しているのはjQueryでの都合なので、今回は不要。
参考: jQuery Event から DOM Event を取る

ドラッグ&ドロップを検知

まずは、ドラッグとドロップにイベントを付けてみる。
これはElmだけで実現できる。
  • elm-package.json ("native-modules": trueは次の節から必要になるので先に書いておいたがまだ不要)
    {
        "version": "0.0.1",
        "summary": "file drag drop example.",
        "repository": "https://github.com/zaneli/drag-drop.git",
        "license": "NYSL",
        "source-directories": [
            "src"
        ],
        "exposed-modules": [],
        "dependencies": {
            "elm-lang/core": "5.1.1 <= v < 6.0.0",
            "elm-lang/html": "2.0.0 <= v < 3.0.0"
        },
        "native-modules": true,
        "elm-version": "0.18.0 <= v < 0.19.0"
    }
    
  • src/App.elm
      module App exposing (..)
      
      import Html exposing (div, program, span, text, Attribute, Html)
      import Html.Attributes exposing (class, id, style)
      import Html.Events exposing (onWithOptions)
      import Json.Decode as JD
      import Native.FileAPI
      
      main : Program Never Model Action
      main =
          program
              { init = init
              , update = update
              , view = view
              , subscriptions = subscriptions
              }
    
      type alias Model =
          { name : String }
    
      init : ( Model, Cmd Action )
      init =
          ( { name = "" }, Cmd.none )
    
      subscriptions : Model -> Sub Action
      subscriptions model =
          Sub.none
    
      type Action
          = Upload
          | Noop
    
      update : Action -> Model -> ( Model, Cmd Action )
      update msg model =
          case msg of
              Upload -> ({model | name = "ファイルをドラッグドロップしました。"}, Cmd.none)
              Noop -> (model, Cmd.none)
    
      view : Model -> Html Action
      view model =
          div []
              [ div
                  [ id "drag-drop-area", style dragDropArea, onDragOver, onDrop ]
                  [ text "ファイルをドラッグドロップしてください。" ]
              , span [ class "file-name" ] [ text model.name ]
              ]
      
      onDragOver : Attribute Action
      onDragOver =
          onWithOptions
              "dragover"
              { preventDefault = True 
              , stopPropagation = False
              }
              (JD.succeed Noop)
      
      onDrop : Attribute Action
      onDrop =
          onWithOptions
              "drop"
              { preventDefault = True 
              , stopPropagation = False
              }
              (JD.succeed Upload)
      
      dragDropArea : List (String, String)
      dragDropArea =
          [ ("width", "80%")
          , ("height", "250px")
          , ("border", "solid 1px #000")
          , ("margin", "10px")
          ]
    
ファイルをドラッグ&ドロップすると「ファイルをドラッグドロップしました。」とだけ表示される。
しかし、どのようなファイルかという情報は取れない。
event.dataTransferに触るために、そこだけ生のJavaScriptを書いてElmから呼べるようにしてみる。

native moduleを書いてElmから呼び出す

native moduleの書き方はElm0.17から変更されたらしく、今回使った0.18でも新しい書き方に合わせる。
参考: Elm 0.17~0.18版 NativeModuleの書き方
    src/App.elm
    module App exposing (..)
    
    import Html exposing (div, program, span, text, Attribute, Html)
    import Html.Attributes exposing (class, id, style)
    import Html.Events exposing (onWithOptions)
    import Json.Decode as JD
    import Native.FileAPI
    
    main : Program Never Model Action
    main =
        program
            { init = init
            , update = update
            , view = view
            , subscriptions = subscriptions
            }
    
    type alias Model =
        { name : String, isReady: Bool }
    
    type Action =
        Noop
    
    init : ( Model, Cmd Action )
    init =
        ( { name = "", isReady = False }, Cmd.none )
    
    subscriptions : Model -> Sub Action
    subscriptions model =
        Sub.none
    
    update : Action -> Model -> ( Model, Cmd Action )
    update msg model =
        let m = if model.isReady then model else addOnDropEvent model
        in case msg of
            Noop ->
                (m, Cmd.none)
    
    view : Model -> Html Action
    view model =
        div []
            [ div
                [ id "drag-drop-area", style dragDropArea, onDragOver, onDrop ]
                [ text "ファイルをドラッグドロップしてください。" ]
            , span
                [ class "file-name" ] [ text model.name ]
            ]
    
    onDragOver : Attribute Action
    onDragOver =
        onWithOptions
            "dragover"
            { preventDefault = True 
            , stopPropagation = False
            }
            (JD.succeed Noop)
    
    onDrop : Attribute Action
    onDrop =
        onWithOptions
            "drop"
            { preventDefault = True 
            , stopPropagation = False
            }
            (JD.succeed Noop)
    
    addOnDropEvent : Model -> Model
    addOnDropEvent model =
        Native.FileAPI.addOnDropEvent
            "drag-drop-area"
            { preventDefault = True 
            , stopPropagation = False
            }
            model
    
    dragDropArea : List (String, String)
    dragDropArea =
        [ ("width", "80%")
        , ("height", "250px")
        , ("border", "solid 1px #000")
        , ("margin", "10px")
        ]
    
    src/Native/FileAPI.js
    var _zaneli$drag_drop$Native_FileAPI = function() {
      function addOnDropEvent(name, options, model) {
        var tag = document.getElementById(name);
        tag.addEventListener("drop", function(event) {
          if (options.preventDefault) {
            event.preventDefault();
          }
          if (options.stopPropagation) {
            event.stopPropagation();
          }
          var files = event.dataTransfer.files;
          if (files.length == 1) {
            model.name = files[0].name + "をドラッグドロップしました。";
          }
        });
        model.isReady = true;
        return model;
      }
      return {
        addOnDropEvent: F3(addOnDropEvent)
      }
    }();
    
どこでNative.FileAPI.addOnDropEventを呼ぶかというのに悩んで、最初はinitでやろうとしていたが、
viewで作られるDOMの作成前になるようでdocument.getElementById(name);でElementが取得できず、
modelにフラグを持たせてupdateの最初の一回のみ呼ぶようにしてみた…が無理やり感がある…。

また、App.elm側に書いているonDropはなくてもよくなるかと思ったが、
ドロップのタイミングでupdateが呼ばれなくなり、
「<ファイル名>をドラッグドロップしました。」の表示が更新されるタイミングが
次回のonDragOverになってしまったので残している。

ゴリ押しで何とか動くものにしてはみたが、あまりElmでやるのに適した題材ではなかったかもしれない。

ファイルのアップロードはよく議題に登る。
との事なので、今後Elmだけで完結するようになる可能性もあるのだろうか?
参考: [Elm] Web API サポート状況
明日はarowMさんの「サーバーサイドの情報をHTMLに埋め込んでElmに渡す」です。

Copyright© 2011-2018 Shunsuke Otani All Right Reserved .