Using ports to deal with files in Elm 0.18

12 augustus 2016 door Tolga Paksoy

Introduction

Implementing file upload in Elm is not straightforward. The language has no support for files or blobs yet. There was a proposition to add it to Elm 0.18 at elm-dev. But it was unsure whether it would make the cut for 0.18 or whether it will be pushed back another release. Until it’s release, a Javascript implementation is necessary.

Javascript has an API for reading files embedded to input fields; the FileReader API. It is even supported by IE10, so no need to worry about older browsers/IE. In this post, we will create a simple form input that will read an image from an input field, and display the image preview. The FileReader API will be used in a Javascript port to read files base64 encoded, and pass it back to our Elm runtime for displaying.

Asking the file from Javascript

Lets look at the port implementation first. This is where the Elm application is initialized, and the Javascript interop behavior is defined. For more information about ports, see this Elm guide page first.

Lets start: first we setup our Elm code with the necessary Msg for asking back file information from Javascript:

import Ports exposing (ImagePortData, fileSelected, fileContentRead)

type Msg
  = ImageSelected
  | ImageRead ImagePortData

Next up, we define the necessary ports inside a seperate port module. This module also includes the ImagePortData type alias:

port module Ports exposing (..)

type alias ImagePortData =
  { contents : String
  , filename : String
  }

port fileSelected : String -> Cmd msg

port fileContentRead : (ImagePortData -> msg) -> Sub msg

We now have two port functions we can use for our file upload scenario. fileSelected is for calling the Javascript interop, and fileContentRead is the receiving function.

Specifying the port behavior

So far, we’ve modeled our Elm behavior for reading file data. Next up, we define the Javascript ports for communicating back to our Elm runtime with the necessary file data.

Lets define fileSelected’s behavior. This port is called when the file input field changes. It receives the input field’s HTML element id as the argument. If the id does not exist, it skips execution. Inside of fileSelected, we use the second port fileContentRead to send the necessary file information back to our Elm application. Note that the object we build has the exact same structure as ImagePortData in the Elm application.

var app = Elm.Main.fullscreen();

app.ports.fileSelected.subscribe(function (id) {
  var node = document.getElementById(id);
  if (node === null) {
    return;
  }

  // If your file upload field allows multiple files, you might
  // want to consider turning this into a `for` loop.
  var file = node.files[0];
  var reader = new FileReader();

  // FileReader API is event based. Once a file is selected
  // it fires events. We hook into the `onload` event for our reader.
  reader.onload = (function(event) {
    // The event carries the `target`. The `target` is the file
    // that was selected. The result is base64 encoded contents of the file.
    var base64encoded = event.target.result;
    // We build up the `ImagePortData` object here that will be passed to our Elm
    // runtime through the `fileContentRead` subscription.
    var portData = {
      contents: base64encoded,
      filename: file.name
    };

    // We call the `fileContentRead` port with the file data
    // which will be sent to our Elm runtime via Subscriptions.
    app.ports.fileContentRead.send(portData);
  });

  // Connect our FileReader with the file that was selected in our `input` node.
  reader.readAsDataURL(file);
});

A sample application

Here’s a quick sample application to show a file input field with preview functionality. Once a file gets selected (assuming we only allow images), we display an img with the src attribute set to the base64 encoded result from the FileReader API.

-- Main.elm
module Main exposing (..)

import Html exposing (..)
import Html.Attributes exposing (src, title, class, id, type_)
import Html.Events exposing (on)
import Json.Decode as JD
import Ports exposing (ImagePortData, fileSelected, fileContentRead)

type Msg
  = ImageSelected
  | ImageRead ImagePortData

type alias Image =
  { contents : String
  , filename : String
  }

type alias Model =
  { id : String
  , mImage : Maybe Image
  }

main : Program Never Model Msg
main =
  program
    { init = init
    , update = update
    , view = view
    , subscriptions = subscriptions
    }

init : ( Model, Cmd Msg )
init =
  ( { id = "ImageInputId"
    , mImage = Nothing
    }
  , Cmd.none
  )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    ImageSelected ->
      ( model
      , fileSelected model.id
      )
    ImageRead data ->
      let
        newImage =
          { contents = data.contents
          , filename = data.filename
          }
      in
        ( { model | mImage = Just newImage }
        , Cmd.none
        )

view : Model -> Html Msg
view model =
  let
    imagePreview =
      case model.mImage of
        Just i ->
          viewImagePreview i
        Nothing ->
          text ""
  in
    div [ class "imageWrapper" ]
      [ input
        [ type_ "file"
        , id model.id
        , on "change"
          (JD.succeed ImageSelected)
        ]
        []
      , imagePreview
      ]

viewImagePreview : Image -> Html Msg
viewImagePreview image =
  img
    [ src image.contents
    , title image.filename
    ]
    []

subscriptions : Model -> Sub Msg
subscriptions model =
 fileContentRead ImageRead
-- Ports.elm
port module Ports exposing (..)

type alias ImagePortData =
  { contents : String
  , filename : String
  }

port fileSelected : String -> Cmd msg

port fileContentRead : (ImagePortData -> msg) -> Sub msg

And now, create an index.html file with the necessary boilerplate to initialise our Elm application called Main:

<!DOCTYPE HTML>
<html>
<head>
  <meta charset="UTF-8">
  <title>Main</title>
</head>

<body>
  // Assumes your Main.elm file is in the root of your project.
  // If the Main file is in a "src" folder, change this line to "/_compile/src/Main.elm"
  <script type="text/javascript" src="/_compile/Main.elm"></script>
  <script type="text/javascript">
    var app = Elm.Main.fullscreen();

    /* add the contents of the ports example above here
       app.ports etc...
     */
  </script>
</body>
</html>

Now use Elm reactor to compile and serve the HTML file:

elm reactor
#> elm-reactor 0.18
#> Listening on http://localhost:8000/

Next up, go to http://localhost:8000 using your browser and choose “index.html” from the files list. The file input will be served. If you pick an image, the FileReader API will return the contents of it and the Elm application will display your chosen image below the input field.

Note: This blog post has also been implemented in a GitHub repo.

Conclusion

In this tutorial, we have seen how easy the Elm interop with Javascript is. The APIs that are not available in Elm yet, can be “worked around” using ports. You define the ports, define the Msg that will be used with the ports, define the Javascript of these ports and you’re ready to go.