blob: ec73a2b009010b2ef0598709d0027866ef9d1938 [file] [log] [blame]
-- Copyright 2020 The Fuchsia Authors. All rights reserved.
-- Use of this source code is governed by a BSD-style license that can be
-- found in the LICENSE file.
module SplitPane exposing
( Model
, Msg(..)
, Orientation(..)
, decode
, encode
, init
, subscriptions
, update
, view
)
{-| This module provides draggable split panes.
It only sets width/height styles. The client is responsible for customizing
these CSS classes:
- .split-container, applied to the overall container div.
- .split-item, applied to the two panes inside the container.
- .split-item--first, applied only to the top/left pane.
- .split-item--second, applied only to the bottom/right pane.
- .split-splitter, applied to the splitter between the two panes.
- .split-{container,item,splitter}--horizontal, applied in horizontal mode.
- .split-{container,item,splitter}--vertical, applied in vertical mode.
-}
import Browser.Dom
import Browser.Events
import Html exposing (Html)
import Html.Attributes as Attributes
import Html.Events as Events
import Json.Decode as Decode
import Json.Encode as Encode
import Task
------------- MODEL ------------------------------------------------------------
type alias Model =
{ -- Current orientation of the splitter.
orientation : Orientation
-- Keep track of whether the split is being dragged and its position.
, state : State
-- HTML id to use for the container div.
, id : String
-- Bounds of the container div.
, bounds : Bounds
}
{-| Orientation can be confusing. This module uses the terms to refer to the
splitter, so Horizontal means top/bottom and Vertical means left/right.
-}
type Orientation
= Horizontal
| Vertical
type State
= Static Position
| Moving Position
{-| The position of the splitter is a fraction from 0 to 1, where 0 means
dragged all the way to the top/left and 1 means all the way to the bottom/right.
-}
type alias Position =
Float
type alias Bounds =
{ x : Pixels
, y : Pixels
, width : Pixels
, height : Pixels
}
{-| Pixels means the CSS pixel unit (not necessary the same as display pixels).
-}
type alias Pixels =
Float
{-| Initializes a split pane. For example:
init Vertical 0.5 "SplitContainer"
To use multiple split containers in a page, they must have unique IDs.
-}
init : Orientation -> Position -> String -> Model
init orientation splitPosition id =
{ orientation = orientation
, state = Static splitPosition
, id = id
, bounds = emptyBounds
}
emptyBounds : Bounds
emptyBounds =
{ x = 0, y = 0, width = 0, height = 0 }
position : State -> Position
position state =
case state of
Static x ->
x
Moving x ->
x
------------- UPDATE -----------------------------------------------------------
type Msg
= NoOp
-- Toggles the orientation.
| ToggleOrientation
-- PreDragStart is necessary to kick off getting the container div bounds.
-- Without it, if you resize the window and then start dragging, there is
-- an annoying flicker effect.
| PreDragStart
-- Start dragging, providing the updated container bounds.
| DragStart Bounds
-- Continue dragging, indicating whether the mouse is still down or not.
| DragMove MouseState Coords
-- Finish dragging.
| DragStop Coords
type MouseState
= MouseUp
| MouseDown
{-| Coords represents a position relative to the top-left of the page.
-}
type alias Coords =
{ pageX : Pixels
, pageY : Pixels
}
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
NoOp ->
( model, Cmd.none )
ToggleOrientation ->
let
orientation =
case model.orientation of
Horizontal ->
Vertical
Vertical ->
Horizontal
in
( { model | orientation = orientation }, Cmd.none )
PreDragStart ->
( model, startDrag model )
DragStart bounds ->
( { model
| bounds = bounds
, state = Moving (position model.state)
}
, Cmd.none
)
DragMove mouseState coords ->
( { model
| state =
case mouseState of
MouseDown ->
Moving (coordsToPosition coords model)
MouseUp ->
Static (position model.state)
}
, Cmd.none
)
DragStop coords ->
( { model
| state = Static (coordsToPosition coords model)
}
, Cmd.none
)
startDrag : Model -> Cmd Msg
startDrag model =
let
element =
Browser.Dom.getElement model.id |> Task.map .element
handle result =
case result of
Ok bounds ->
DragStart bounds
Err _ ->
NoOp
in
Task.attempt handle element
coordsToPosition : Coords -> Model -> Position
coordsToPosition coords model =
let
fraction =
case model.orientation of
Horizontal ->
(coords.pageY - model.bounds.y) / model.bounds.height
Vertical ->
(coords.pageX - model.bounds.x) / model.bounds.width
in
clamp 0 1 fraction
------------- SUBSCRIPTIONS ----------------------------------------------------
subscriptions : Model -> Sub Msg
subscriptions model =
case model.state of
Static _ ->
Sub.none
Moving _ ->
Sub.batch
[ Browser.Events.onMouseMove
(Decode.map2 DragMove decodeMouseState decodeCoords)
, Browser.Events.onMouseUp
(Decode.map DragStop decodeCoords)
]
decodeMouseState : Decode.Decoder MouseState
decodeMouseState =
Decode.field "buttons" <|
Decode.map
(\buttons ->
case buttons of
1 ->
MouseDown
_ ->
MouseUp
)
Decode.int
decodeCoords : Decode.Decoder Coords
decodeCoords =
Decode.map2 Coords
(Decode.field "pageX" Decode.float)
(Decode.field "pageY" Decode.float)
------------- VIEW -------------------------------------------------------------
{-| Creates the split pane view. The toMsg parameter converts a SplitPane.Msg to
the top-level Msg type of the app.
-}
view : Html msg -> Html msg -> (Msg -> msg) -> Model -> Html msg
view firstView secondView toMsg model =
let
orientationModifier =
case model.orientation of
Horizontal ->
"horizontal"
Vertical ->
"vertical"
classWithOrientation className =
Attributes.class
(className ++ " " ++ className ++ "--" ++ orientationModifier)
-- Keep the cursor in the center of the resize bar.
firstItemSize =
size model.orientation
("calc("
++ String.fromFloat (position model.state * 100)
++ "% - "
++ String.fromFloat (splitterSize / 2)
++ "px)"
)
-- But don't let the half the bar disappear under the far edge.
firstItemMaxSize =
maxSize
model.orientation
("calc(100% - " ++ String.fromFloat splitterSize ++ "px)")
in
Html.div
[ Attributes.id model.id
, classWithOrientation "split-container"
]
[ Html.div
[ classWithOrientation "split-item"
, Attributes.class "split-item--first"
, firstItemSize
, firstItemMaxSize
]
[ firstView ]
, Html.div
[ classWithOrientation "split-splitter"
, size model.orientation (String.fromFloat splitterSize ++ "px")
, Events.onMouseDown (toMsg PreDragStart)
]
[]
, Html.div
[ classWithOrientation "split-item"
, Attributes.class "split-item--second"
]
[ secondView ]
]
splitterSize : Pixels
splitterSize =
8
size : Orientation -> String -> Html.Attribute msg
size orientation value =
case orientation of
Horizontal ->
Attributes.style "height" value
Vertical ->
Attributes.style "width" value
maxSize : Orientation -> String -> Html.Attribute msg
maxSize orientation value =
case orientation of
Horizontal ->
Attributes.style "max-height" value
Vertical ->
Attributes.style "max-width" value
------------- ENCODE / DECODE --------------------------------------------------
{-| Encodes the model. This only includes the orientation and drag position. It
omits the ID, bounds, and static/moving, since persisting those is not useful.
-}
encode : Model -> Encode.Value
encode model =
Encode.object
[ ( "orientation", encodeOrientation model.orientation )
, ( "state", Encode.float (position model.state) )
]
{-| Decodes the model. You must provide the ID.
-}
decode : String -> Decode.Decoder Model
decode id =
Decode.map4 Model
(Decode.field "orientation" decodeOrientation)
(Decode.field "state" (Decode.map Static Decode.float))
(Decode.succeed id)
(Decode.succeed emptyBounds)
encodeOrientation : Orientation -> Encode.Value
encodeOrientation orientation =
case orientation of
Horizontal ->
Encode.string "horizontal"
Vertical ->
Encode.string "vertical"
decodeOrientation : Decode.Decoder Orientation
decodeOrientation =
Decode.string
|> Decode.andThen
(\string ->
case string of
"horizontal" ->
Decode.succeed Horizontal
"vertical" ->
Decode.succeed Vertical
_ ->
Decode.fail ("Invalid orientation: " ++ string)
)