[036.4] Synchronizing Our SVG Editor with Firebase

Using Firebase to synchronize our Elm model, providing persistence and collaboration.

Subscribe now

Synchronizing Our SVG Editor with Firebase [02.02.2017]

In the last episode, we set up Firebase for our SVG Editor. Today we'll store our model in Firebase and look at the basics of synchronization. Let's get started.

Project

We're starting off with dailydrip/elm-svg-editor tagged before this episode.

We know we want to store our model on Firebase. Let's begin by writing an encoder. We'll just store and retrieve the shapes part of our Model, since the rest of it is related strictly to our user interface and shouldn't be shared:

vim src/Encoder.elm
module Encoder exposing (shapesEncoder)

import Json.Encode exposing (..)
import Model exposing (Shape(..))
import Dict exposing (Dict)


-- We'll add a dictEncoder that takes an encoder and a dictionary and encodes it
-- as an object.
shapesEncoder : Dict Int Shape -> Value
shapesEncoder shapes =
    dictEncoder shapeEncoder shapes


-- Our dictEncoder takes a function turning the value of our dict into an
-- encoded value, and a dict, and produces a value
dictEncoder : (a -> Value) -> Dict comparable a -> Value
dictEncoder enc dict =
    Dict.toList dict
        |> List.map (\( k, v ) -> ( toString k, enc v ))
        |> object


-- Then we just make a straightforward encoder for our shapes, putting the type
-- of shape into a `type` field in the object.
shapeEncoder : Shape -> Value
shapeEncoder shape =
    case shape of
        Rect rectModel ->
            object <|
                [ ( "type", string "rect" )
                , ( "x", float rectModel.x )
                , ( "y", float rectModel.y )
                , ( "width", float rectModel.width )
                , ( "height", float rectModel.height )
                , ( "stroke", string rectModel.stroke )
                , ( "strokeWidth", float rectModel.strokeWidth )
                , ( "fill", string rectModel.fill )
                ]

        Circle circleModel ->
            object <|
                [ ( "type", string "circle" )
                , ( "cx", float circleModel.cx )
                , ( "cy", float circleModel.cy )
                , ( "r", float circleModel.r )
                , ( "stroke", string circleModel.stroke )
                , ( "strokeWidth", float circleModel.strokeWidth )
                , ( "fill", string circleModel.fill )
                ]

        Text textModel ->
            object <|
                [ ( "type", string "text" )
                , ( "x", float textModel.x )
                , ( "y", float textModel.y )
                , ( "content", string textModel.content )
                , ( "fontFamily", string textModel.fontFamily )
                , ( "fontSize", int textModel.fontSize )
                , ( "stroke", string textModel.stroke )
                , ( "strokeWidth", float textModel.strokeWidth )
                , ( "fill", string textModel.fill )
                ]

Next, we want to add a port that will send our encoded value to firebase:

port module Ports exposing (receiveSvgMouseCoordinates, persistShapes)
-- ...
import Json.Encode exposing (Value)
-- ...
-- OUTBOUND PORTS
port persistShapes : Value -> Cmd msg

Now we'll add a function to our update that will encode the shapes and send them out the port:

module Update exposing (update)
-- ...
import Encoder exposing (shapesEncoder)
import Ports exposing (persistShapes)
-- ...
sendShapes : Dict Int Shape -> Cmd Msg
sendShapes shapes =
    shapes
        |> shapesEncoder
        |> persistShapes

Now we can just persist the shapes any time we make changes to them. We'll also add a function that takes (Model, Cmd Msg) and adds the sendShapes command so we can just pipe through it.

module Update exposing (update)
-- ...
andSendShapes : ( Model, Cmd Msg ) -> ( Model, Cmd Msg )
andSendShapes ( model, cmd ) =
    ( model
    , Cmd.batch
        [ cmd
        , sendShapes model.shapes
        ]
    )
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ mouse } as model) =
    case msg of
        -- ...
        MouseSvgMove pos ->
            let
                -- ...
            in
                ({ nextModel | mouse = nextMouse } ! [])
                    |> andSendShapes
        -- ...
        AddShape shape ->
            ( model |> addShape shape
            , Cmd.none
            )
                |> andSendShapes
        -- ...
        SelectedShapeAction shapeAction ->
            handleShapeAction shapeAction model
                |> andSendShapes
-- ...

With that, we're sending the shapes out of the port each time they change. However, we aren't yet doing anything with the value. We'll hardcode the reference URL in firebase for now for our persistence, subscribe to this port, and store it:

// ...
let ref = database.ref('shapes/2')
app.ports.persistShapes.subscribe((shapes) => {
  ref.set(shapes)
})
// ...

Now if you add a shape or move one around, you can see the new document get stored on firebase at /shapes/2. Now let's handle loading it initially. To do this, we'll need to write a JSON decoder for our Dict Int Shape. We'll bring in elm-decode-pipeline and then write it:

elm-package install -y NoRedInk/elm-decode-pipeline
vim src/Decoder.elm
module Decoder exposing (shapesDecoder)

import Json.Decode exposing (..)
import Json.Decode.Pipeline
    exposing
        ( decode
        , required
        , custom
        )
import Model exposing (Shape(..), RectModel, CircleModel, TextModel)
import Dict exposing (Dict)


-- We'll make a shapes decoder, that decodes our dict
-- Because the dict decoder always returns `Dict String a` we'll need to map the
-- keys to integers.
shapesDecoder : Decoder (Dict Int Shape)
shapesDecoder =
    dict shapeDecoder
        |> map parseIntKeys


-- Here we just convert the string keys into their corresponding integers
parseIntKeys : Dict String Shape -> Dict Int Shape
parseIntKeys stringShapes =
    stringShapes
        |> Dict.toList
        |> List.map
            (\( k, v ) ->
                ( k |> String.toInt |> Result.withDefault 0
                , v
                )
            )
        |> Dict.fromList


-- We switch on the type, to handle decoding the different shapes.
shapeDecoder : Decoder Shape
shapeDecoder =
    field "type" string
        |> andThen specificShapeDecoder


-- Here we map a function into the corresponding Shape decoder
specificShapeDecoder : String -> Decoder Shape
specificShapeDecoder typeStr =
    case typeStr of
        "rect" ->
            decode Rect
                |> custom rectModelDecoder

        "circle" ->
            decode Circle
                |> custom circleModelDecoder

        "text" ->
            decode Text
                |> custom textModelDecoder

        _ ->
            fail "unknown shape type"


-- Then we just decode each of rect, circle, and text models
rectModelDecoder : Decoder RectModel
rectModelDecoder =
    decode RectModel
        |> required "x" float
        |> required "y" float
        |> required "width" float
        |> required "height" float
        |> required "stroke" string
        |> required "strokeWidth" float
        |> required "fill" string


circleModelDecoder : Decoder CircleModel
circleModelDecoder =
    decode CircleModel
        |> required "cx" float
        |> required "cy" float
        |> required "r" float
        |> required "stroke" string
        |> required "strokeWidth" float
        |> required "fill" string


textModelDecoder : Decoder TextModel
textModelDecoder =
    decode TextModel
        |> required "x" float
        |> required "y" float
        |> required "content" string
        |> required "fontFamily" string
        |> required "fontSize" int
        |> required "stroke" string
        |> required "strokeWidth" float
        |> required "fill" string

With this, we should be able to decode our shapes when we retrieve them from firebase. We can add an inbound port that takes a Value and decodes it with our decoder:

port module Ports
    exposing
        ( -- ...
        , receiveShapes
        )
-- ...
-- INBOUND PORTS
-- ...
port receiveShapes : (Value -> msg) -> Sub msg
-- ...

Now we want to add the subscription:

module App exposing (..)
-- ...
subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [ -- ...
        , Ports.receiveShapes ReceiveShapes
        ]

And we'll add a ReceiveShapes Msg:

module Msg
-- ...
import Json.Encode exposing (Value)
-- ...
type Msg
    -- ...
    | ReceiveShapes Value

Finally, we'll decode the value in our update and, if it's successful, replace the shapes in the model:

module Update exposing (update)
-- ...
import Json.Decode as Decode
import Decoder exposing (shapesDecoder)


update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ mouse } as model) =
    case msg of
        -- ...
        ReceiveShapes value ->
            value
                |> Decode.decodeValue shapesDecoder
                |> Result.map (\shapes -> { model | shapes = shapes } ! [])
                |> Result.withDefault (model ! [])

Now all that's left is to send in our value from the JavaScript side by listening to Firebase:

ref.on('value', (snapshot) => {
  console.log(snapshot.val())
  app.ports.receiveShapes.send(snapshot.val())
})

If we move some shapes around and refresh...it doesn't quite work. If we look at the console, that's because we've gotten back an array of values, rather than an object. What gives? It turns out that this is behaviour that is implemented in the Firebase client library.

It's worth noting that this issue would have been avoided if we weren't storing our data as a Dict Int Shape, and that it's just a quirk of how Firebase works.

Now that we know that, we can avoid the behaviour by adding a string key ignoreme with value false, or anything else. Let's update our encoder:

module Encoder exposing (shapesEncoder)
-- ...
dictEncoder : (a -> Value) -> Dict comparable a -> Value
dictEncoder enc dict =
    Dict.toList dict
        |> List.map (\( k, v ) -> ( toString k, enc v ))
        |> (::) ( "ignoreme", bool False )
        |> object

Now we can strip that value out before we send it across the port:

ref.on('value', (snapshot) => {
  let val = snapshot.val()
  delete val.ignoreme
  console.log(val)
  app.ports.receiveShapes.send(val)
})

Now if we refresh our app, it works! As a bonus, we can open up the app in another window and see synchronization between them. It's pretty terrible though if two people are actively using it since we're replacing the entire state each time, which means they overwrite each other frequently. Still, in some cases it might not be so bad.

What I tend to prefer to do for synchronization is build a server in Phoenix. Then I create a GenServer that is in essence a mirror of my Elm model's synchronized parts, and handle the same inbound events that correspond to Msgs that update those parts. From there, I can use Phoenix Channels to send those messages out, and they'll be serialized on the server.

Summary

In today's episode, we saw how to introduce Firebase to an Elm application, using it for persistence and real-time collaboration. To go from here to a generally useful app, all we'll need to add is authentication and generate a new ID for each of our new drawing spaces, which is also a feature that Firebase can help with.

I'm really excited about this and expect to use it a lot going forward. I hope you enjoyed it. See you soon!

Resources