In the last episode we fixed a few usability issues in our app. Today we'll tackle another. Right now there's no way to bring a shape in front of another shape, or push it behind. We can do better. Let's get started.

Project

Since the last episode I did a little more cleanup and introduced the concept of "selected shape actions" in the sidebar - these are icons that show up that can affect the selected shape - we'll use it to provide the ability to reorder them, for now.

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

We have buttons in the sidebar that are emitting actions to send a shape backward or all the way to the back, or to bring a shape forward or all the way to the front.

shapeActions : List ( ShapeAction, Html Msg )
shapeActions =
    [ ( SendToBack, icon "fast-backward" )
    , ( SendBackward, icon "backward" )
    , ( BringForward, icon "forward" )
    , ( BringToFront, icon "fast-forward" )
    ]

Before we move on, we should talk about how SVG documents order their shapes. They are drawn in the order that they appear in the document - earlier shapes appear behind later shapes. Now let's look at how we render them to the document:

viewShapes : Tool -> Maybe Int -> Dict Int Shape -> List (Svg Msg)
viewShapes selectedTool maybeSelectedShapeId shapesDict =
    shapesDict
        |> Dict.map (viewShape selectedTool maybeSelectedShapeId)
        |> Dict.toList
        |> List.map Tuple.second

They're ordered by ID. We could change the shapes' IDs to reorder them, but that sounds terrible. Instead, let's introduce a new dictionary into our model that will be used to specify a given shape's position - if there's no position in the ordering dictionary, they'll be given the default order of 0.

module Model
-- ...
type alias Model =
    { -- ...
    , shapeOrdering : Dict Int Int
    }
-- ...
initialModel : Model
initialModel =
    { -- ...
    , shapeOrdering = Dict.empty
    }
-- ...

Now we'll modify the Update to introduce or update the value in the ordering dictionary when we click the selected shape action buttons:

vim src/Update.elm
-- ...
handleShapeAction : ShapeAction -> Model -> ( Model, Cmd Msg )
handleShapeAction shapeAction ({ selectedShapeId, shapeOrdering } as model) =
    case shapeAction of
        SendToBack ->
            { model
                | shapeOrdering = sendShapeToBack selectedShapeId shapeOrdering
            }
                ! []

        _ ->
            model ! []


sendShapeToBack : Maybe Int -> Dict Int Int -> Dict Int Int
sendShapeToBack maybeSelectedShapeId shapeOrdering =
    case maybeSelectedShapeId of
        Nothing ->
            shapeOrdering

        Just shapeId ->
            let
                lowestOrder : Int
                lowestOrder =
                    shapeOrdering
                        |> Dict.remove shapeId
                        |> Dict.values
                        |> List.minimum
                        |> Maybe.withDefault 0
            in
                Dict.insert shapeId (lowestOrder - 1) shapeOrdering

With this, we can modify the ordering of our shapes. Next, let's update our View to take this ordering into account when generating the SVG document:

vim src/View.elm
module View exposing (view)
-- ...
view : Model -> Html Msg
view model =
    div []
        [ -- ...
        , div
            [ class Pure.grid ]
            [ sidebar model.selectedShapeId model.shapes model.mouse model.selectedTool
-- We're passing the shapeOrdering as the last argument to drawingArea
            , drawingArea
                model.selectedShapeId
                model.shapes
                model.selectedTool
                model.mouse
                model.shapeOrdering
            ]
        ]


-- We accept it here and pass it on to viewShapes
drawingArea :
    Maybe Int
    -> Dict Int Shape
    -> Tool
    -> MouseModel
    -> Dict Int Int
    -> Html Msg
drawingArea maybeSelectedShapeId shapesDict selectedTool mouse shapeOrdering =
    section
        [ class <| "drawing-area " ++ Pure.unit [ "7", "8" ] ]
        [ svg
            [ -- ...
            ]
            (viewShapes selectedTool
                maybeSelectedShapeId
                shapesDict
                shapeOrdering
            )
        ]


-- And we take it into account when rendering the shapes into the document
viewShapes : Tool -> Maybe Int -> Dict Int Shape -> Dict Int Int -> List (Svg Msg)
viewShapes selectedTool maybeSelectedShapeId shapesDict shapeOrdering =
    shapesDict
        |> Dict.map (viewShape selectedTool maybeSelectedShapeId)
        |> Dict.toList
        |> List.sortBy
            (\( id, _ ) ->
                Dict.get id shapeOrdering
                    |> Maybe.withDefault 0
            )
        |> List.map Tuple.second

With that, we can move shapes atop one another and send to back to change the ordering. It's easy to implement BringToFront along the same lines:

handleShapeAction : ShapeAction -> Model -> ( Model, Cmd Msg )
handleShapeAction shapeAction ({ selectedShapeId, shapeOrdering } as model) =
    case shapeAction of
        -- ...
        BringToFront ->
            { model
                | shapeOrdering = bringShapeToFront selectedShapeId shapeOrdering
            }
                ! []
-- ...
bringShapeToFront : Maybe Int -> Dict Int Int -> Dict Int Int
bringShapeToFront maybeSelectedShapeId shapeOrdering =
    case maybeSelectedShapeId of
        Nothing ->
            shapeOrdering

        Just shapeId ->
            let
                highestOrder : Int
                highestOrder =
                    shapeOrdering
                        |> Dict.remove shapeId
                        |> Dict.values
                        |> List.maximum
                        |> Maybe.withDefault 0
            in
                Dict.insert shapeId (highestOrder + 1) shapeOrdering

Now we just want to be able to handle moving a bit more naturally, bit by bit. We'll implement these particularly naively:

handleShapeAction : ShapeAction -> Model -> ( Model, Cmd Msg )
handleShapeAction shapeAction ({ selectedShapeId, shapeOrdering } as model) =
    case shapeAction of
        -- ...
        SendBackward ->
            { model
                | shapeOrdering = sendShapeBackward selectedShapeId shapeOrdering
            }
                ! []

        BringForward ->
            { model
                | shapeOrdering = bringShapeForward selectedShapeId shapeOrdering
            }
                ! []
        -- ...


existingOrder : Int -> Dict Int Int -> Int
existingOrder shapeId shapeOrdering =
    shapeOrdering
        |> Dict.get shapeId
        |> Maybe.withDefault 0


sendShapeBackward : Maybe Int -> Dict Int Int -> Dict Int Int
sendShapeBackward maybeSelectedShapeId shapeOrdering =
    case maybeSelectedShapeId of
        Nothing ->
            shapeOrdering

        Just shapeId ->
            Dict.insert shapeId ((existingOrder shapeId shapeOrdering) - 1) shapeOrdering


bringShapeForward : Maybe Int -> Dict Int Int -> Dict Int Int
bringShapeForward maybeSelectedShapeId shapeOrdering =
    case maybeSelectedShapeId of
        Nothing ->
            shapeOrdering

        Just shapeId ->
            Dict.insert shapeId ((existingOrder shapeId shapeOrdering) + 1) shapeOrdering

And with that natural ordering works, though with some quirks since you can keep moving a shape backwards piece by piece and end up having to bring it forward quite a bit in order to go above the next shape.

Summary

In today's episode we introduced the ability to reorder shapes. There's quite a bit of duplication that we can remove, and we'll look at doing that tomorrow. See you soon!

Resources