[035.2] Editing Shapes Interactively


Adding resize handles to change the size of our shapes interactively.

Subscribe now

In the last episode, we added the ability to drag shapes around the canvas. Today, we'll add a handle to resize shapes interactively. Let's get started.

Editing shapes

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

Now that we can move shapes around, it would be nice to be able to change their size. We'll add a handle when shapes are selected, and dragging the handle will change the width and height of Rects and the radius or Circles.

Adding a new Drag msg

Let's start by adding a DragResize msg:

module Drag exposing (DragAction(..))


type DragAction
    = DragMove
    | DragResize -- <--

We'll need to handle this in our update in order to compile. For now we'll make it a no-op:

module App exposing (..)
-- ...
handleDragAction : DragAction -> Int -> Shape -> SvgPosition -> Model -> Model
handleDragAction dragAction shapeId shape pos ({ mouse } as model) =
    let
        newShape : Shape
        newShape =
            case dragAction of
                -- ...
                DragResize ->
                  shape
-- ...

So now we can compile again. Next, we'd like to add the user interface component that allows us to send this action:

module View exposing (view)
-- ...
-- We'll add a function to produce a dragHandle at provided coordinates
dragHandleWidth : Int
dragHandleWidth =
    20


dragHandle : ( Float, Float ) -> Svg Msg
dragHandle ( x_, y_ ) =
    rect
        [ x <| toString x_
        , y <| toString y_
        , width (toString dragHandleWidth)
        , height (toString dragHandleWidth)
        , stroke "yellow"
        , strokeWidth "2"
        , strokeDasharray "4,4"
        , fill "transparent"
        , SA.class "selection-drag-handle"
        , onMouseDownPreventingDefault <| BeginDrag DragResize
        ]
        []

-- Then we'll add the dragHandles to each of the selected shape view functions
viewRect : Bool -> Int -> RectModel -> Svg Msg
viewRect selected shapeId rectModel =
    let
        -- ...
        groupChildren =
            if selected then
                [ viewUnselectedRect shapeId rectModel
                , rectSelection
                -- Here we're adding a dragHandle
                , dragHandle
                    ( rectModel.x + rectModel.width
                    , rectModel.y + rectModel.height
                    )
                ]
            else
                [ viewUnselectedRect shapeId rectModel ]
    in
        g [] groupChildren


viewCircle : Bool -> Int -> CircleModel -> Svg Msg
viewCircle selected shapeId circleModel =
    let
        -- ...
        groupChildren =
            if selected then
                [ viewUnselectedCircle shapeId circleModel
                , circleSelection
                -- And another drag handle, positioned slightly differently
                , dragHandle
                    ( circleModel.cx + circleModel.r
                    , circleModel.cy
                    )
                ]
            else
                [ viewUnselectedCircle shapeId circleModel ]
    in
        g [] groupChildren
-- ...

So now we can see the handles we'll use to resize our shapes. It'd be nice to have some indicator on our mouse cursor explaining what this is for, so we can do that in the css:

vim src/main.css
/* ... */
.selection-drag-handle {
  /* I don't get to use this cursor often enough! */
  cursor: nwse-resize;
}

OK, now we can see that we've got a nice resize handle on the shapes. Next, we need to handle the resize itself. We'll start by adding support for resizing circles:

module App exposing (..)
-- ...
handleDragAction : DragAction -> Int -> Shape -> SvgPosition -> Model -> Model
handleDragAction dragAction shapeId shape pos ({ mouse } as model) =
    let
        newShape : Shape
        newShape =
            case dragAction of
                -- ...
                DragResize ->
                    case ( shape, model.comparedShape ) of
                        ( Circle circleModel, Just (Circle compCircle) ) ->
                            let
                                newR =
                                    abs (pos.x - circleModel.cx)
                            in
                                Circle
                                    { circleModel
                                        | r = newR
                                    }

                        _ ->
                            shape

Here we're just taking the absolute value of the difference between the x position of the mouse pointer and the x position of the center of the cursor, and using that to determine the circle's new radius. Of course, people might want to do the same thing with the y position, so we can take the max of those two values:

                DragResize ->
                    case ( shape, model.comparedShape ) of
                        ( Circle circleModel, Just (Circle compCircle) ) ->
                            let
                                newRX =
                                    abs (pos.x - circleModel.cx)

                                newRY =
                                    abs (pos.y - circleModel.cy)

                                newR =
                                    max newRX newRY
                            in
                                Circle
                                    { circleModel
                                        | r = newR
                                    }

                        _ ->
                            shape

So this lets us resize Circles nicely. What about Rects? Let's start with a naive solution for a single axis:

                DragResize ->
                    case ( shape, model.comparedShape ) of
                        ( Rect rectModel, Just (Rect compRect) ) ->
                            let
                                newWidth =
                                    pos.x - compRect.x
                            in
                                Rect
                                    { rectModel
                                        | width = newWidth
                                    }

This seems to work fine initially for resizing the width. But what happens when our width goes negative? Of course, we have problems. What is a negative width?

So in this case, we need to be able to handle things differently depending on whether the cursor is to the left of the shape. Let's just add some basic conditionals to handle the < 0 case differently. To deal with this, we now need to handle updating both the x and the width of our Rect:

                DragResize ->
                    case ( shape, model.comparedShape ) of
                        ( Rect rectModel, Just (Rect compRect) ) ->
                            let
                                ( newX, newWidth ) =
                                    if pos.x <= compRect.x then
                                        ( pos.x, compRect.x - pos.x )
                                    else
                                        ( compRect.x, pos.x - compRect.x )
                            in
                                Rect
                                    { rectModel
                                        | width = newWidth
                                        , x = newX
                                    }

Now, our resize works as expected horizontally. It's easy to duplicate this logic for vertical resizing:

                DragResize ->
                    case ( shape, model.comparedShape ) of
                        ( Rect rectModel, Just (Rect compRect) ) ->
                            let
                                ( newX, newWidth ) =
                                    if pos.x <= compRect.x then
                                        ( pos.x, compRect.x - pos.x )
                                    else
                                        ( compRect.x, pos.x - compRect.x )

                                ( newY, newHeight ) =
                                    if pos.y <= compRect.y then
                                        ( pos.y, compRect.y - pos.y )
                                    else
                                        ( compRect.y, pos.y - compRect.y )
                            in
                                Rect
                                    { rectModel
                                        | height = newHeight
                                        , width = newWidth
                                        , x = newX
                                        , y = newY
                                    }

With that, we have nice resizing working. Of course, there's a bit of frustrating behaviour where the Rect is only resizable from its bottom-right corner, but we'll not worry about that today.

Summary

In today's episode, we expanded on our Drag actions to support resizing shapes. There's custom code to handle each shape, as should be expected. We've also identified a quirk we'd like to sort out in the future - we can only resize from one corner. For now it's pretty great to have this feature so easily. I hope you enjoyed it. See you soon!

Resources

Published on 01.24.2017