├── .gitignore ├── screenshot_1.png ├── examples ├── just-crop │ ├── README.md │ ├── elm.json │ ├── Main.elm │ └── src │ │ └── Main.elm ├── crop-and-get-cropped-image │ ├── Makefile │ ├── pinnacles.jpg │ ├── README.md │ ├── index.html │ ├── elm.json │ └── src │ │ └── Main.elm └── README.md ├── elm.json ├── .travis.yml ├── src ├── ImageCrop │ ├── extract_cropped_image.js │ └── Export.elm └── ImageCrop.elm ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff 2 | -------------------------------------------------------------------------------- /screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berenddeboer/elm-image-crop/HEAD/screenshot_1.png -------------------------------------------------------------------------------- /examples/just-crop/README.md: -------------------------------------------------------------------------------- 1 | # How to run 2 | 3 | Simply type `elm reactor` in this directory, then follow the link. 4 | -------------------------------------------------------------------------------- /examples/crop-and-get-cropped-image/Makefile: -------------------------------------------------------------------------------- 1 | all: elm.js 2 | 3 | elm.js: src/Main.elm ../../src/ImageCrop.elm 4 | elm make src/Main.elm --output elm.js 5 | -------------------------------------------------------------------------------- /examples/crop-and-get-cropped-image/pinnacles.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berenddeboer/elm-image-crop/HEAD/examples/crop-and-get-cropped-image/pinnacles.jpg -------------------------------------------------------------------------------- /examples/crop-and-get-cropped-image/README.md: -------------------------------------------------------------------------------- 1 | # How to run 2 | 3 | Run `elm reactor` in the root of the project, not in this directory. 4 | 5 | That way we don't have CORS issues, and you can load the 6 | [index.html](http://localhost:8000/examples/crop-and-get-cropped-image/index.html). 7 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | You'll find two examples here: 4 | 5 | 1. [Just crop](just-crop) will show the basics, but does not allow you to use the crop. 6 | 7 | 2. [Crop and retieve](crop-and-retrieve-cropped-image) adds the code 8 | to extract that image and show just the cropped image below. 9 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "berend/elm-image-crop", 4 | "summary": "A pack of monoids in the category of endofunctors", 5 | "license": "BSD-3-Clause", 6 | "version": "1.0.0", 7 | "exposed-modules": [ 8 | "ImageCrop", 9 | "ImageCrop.Export" 10 | ], 11 | "elm-version": "0.19.0 <= v < 0.20.0", 12 | "dependencies": { 13 | "elm/core": "1.0.2 <= v < 2.0.0", 14 | "elm/html": "1.0.0 <= v < 2.0.0", 15 | "elm/json": "1.1.3 <= v < 2.0.0", 16 | "elm/svg": "1.0.1 <= v < 2.0.0", 17 | "mpizenberg/elm-pointer-events": "4.0.1 <= v < 5.0.0" 18 | }, 19 | "test-dependencies": {} 20 | } 21 | -------------------------------------------------------------------------------- /examples/crop-and-get-cropped-image/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
11 |
12 |
13 | # Installation
14 |
15 | This image crop cannot be installed with `elm` as it has to use a port
16 | module, because Elm does not support Canvas. So you'll have to install it manually.
17 |
18 | 1. In your Elm application root directory:
19 |
20 | git clone git@github.com:berenddeboer/elm-image-crop.git
21 |
22 | 2. Then add `elm.json` so to list the directory under your "source directories", something like this:
23 |
24 | "source-directories": [
25 | "src",
26 | "elm-image-scrop"
27 | ],
28 |
29 | 3. Add the following dependencies to your project:
30 |
31 | elm install elm/json
32 | elm install elm/svg
33 | elm install mpizenberg/elm-pointer-events
34 |
35 |
36 | # Just cropping
37 |
38 | See the `examples` directory.
39 |
40 |
41 | Basic steps:
42 |
43 | 1. Import the CropImage module to your module.
44 |
45 | 2. Add an `ImageCrop.Model` type to your model.
46 |
47 | 3. Add `GotImageCropMsg` to your `Msg` type.
48 |
49 | 4. Add a div to your view:
50 |
51 | div
52 | [ class "image-crop-picture"
53 | , style "max-width" "100%"
54 | ]
55 | [ Html.map GotImageCropMsg ( ImageCrop.view model.url model.cropSettings) ]
56 |
57 | 5. Handle this new msg in your `update` function:
58 |
59 | case msg of
60 | GotImageCropMsg subMsg ->
61 | let
62 | ( cropSettings, cmd ) = ImageCrop.update subMsg model.cropSettings
63 | in
64 | ( { model | cropSettings = cropSettings } )
65 |
66 |
67 | # Retrieving the cropped image
68 |
69 | Letting a user crop the image is just step one. You want to retrieve
70 | the cropped image as well. That will require you to include a piece of
71 | javascript. You may also run into CORS issues: you cannot extract part
72 | of an image that does not belong to your website unless that third
73 | party website has told you this is OK.
74 |
75 | 6. Add two more message: one for the click which initiates
76 | the action to extract the image, the other to handle the callback
77 | from JavaScript where the actual extraction process takes place.
78 |
79 | So your Msg looks like this:
80 |
81 | type Msg
82 | = GotImageCropMsg ImageCrop.Msg
83 | | SaveProfilePicture
84 | | GotCroppedImage (Result Decode.Error String)
85 |
86 |
87 | 7. Update your `update` function to handle these, see
88 | [Main.elm](examples/crop-and-get-cropped-image/src/Main.elm) for an
89 | example.
90 |
91 | 8. Add a subscription to handle the callback from JavaScript:
92 |
93 | subscriptions model =
94 | ImageCrop.Export.croppedImage (decodeUrl >> GotCroppedImage)
95 |
96 | decodeUrl : Decode.Value -> Result Decode.Error String
97 | decodeUrl =
98 | Decode.decodeValue Decode.string
99 |
100 | As you can see the callback just returns a url, a data url, of the
101 | extracted image. You can store this in your model, or use it as
102 | part of an HTTP request which store the extracted image in a
103 | backend or so.
104 |
--------------------------------------------------------------------------------
/src/ImageCrop.elm:
--------------------------------------------------------------------------------
1 | module ImageCrop exposing
2 | ( Model
3 | , Msg
4 | , update
5 | , view
6 | )
7 |
8 |
9 | {-| Make an ImageCrop component available.
10 |
11 | @docs Model
12 | @docs Msg
13 | @docs update
14 | @docs view
15 | -}
16 |
17 | import Html exposing (Attribute, Html, div, img, text)
18 | import Html.Attributes as Html exposing (class, src, style)
19 | import Html.Events exposing (on)
20 | import Html.Events.Extra.Touch as Touch
21 | import Html.Lazy exposing (lazy)
22 | import Json.Decode as Json
23 | import Svg exposing (g, path, polyline, svg)
24 | import Svg.Attributes exposing (d, fill, id, opacity, pointerEvents, points, preserveAspectRatio, stroke, strokeWidth, transform, viewBox)
25 | import Svg.Events exposing (onMouseDown, onMouseUp)
26 |
27 |
28 | -- MODEL
29 |
30 | {-| The model to track the internal state of this component.
31 | -}
32 |
33 | type alias Model =
34 | { left : Int
35 | , top : Int
36 | , length : Int
37 | , minimum_length : Int
38 | , maximum_length : Int
39 | , image_width : Int
40 | , image_height : Int
41 | , natural_width : Int
42 | , natural_height : Int
43 | , rectangle_state : RectangleState
44 | }
45 |
46 |
47 | type RectangleState
48 | = AtRest
49 | | Moving Int Int Int Int
50 | | Resizing MoveEdge Int Int Int Int Int
51 | | Pinching Int Int Int ( Float, Float ) ( Float, Float )
52 |
53 |
54 | type MoveEdge
55 | = MoveTopLeft
56 | | MoveTopRight
57 | | MoveBottomLeft
58 | | MoveBottomRight
59 |
60 | type WindDirection
61 | = NorthWest
62 | | NorthEast
63 | | SouthWest
64 | | SouthEast
65 |
66 |
67 | {-| Create an initial model.
68 |
69 | `width' and `height' are the dimensions of the image as scaled in the
70 | browser. `natural_width' and `natural_height' are the original dimension
71 | of the image.
72 | -}
73 | initialModel : Float -> Float -> Float -> Float -> Model
74 | initialModel width height natural_width natural_height =
75 | let
76 | proposed_length = round (width / 2)
77 | int_width = round width
78 | int_height = round height
79 | int_natural_width = round natural_width
80 | int_natural_height = round natural_height
81 | minimum_length = 100 -- Pretty arbitrary number
82 | maximum_length = min int_width int_height
83 | length =
84 | if proposed_length >= minimum_length then
85 | if proposed_length <= maximum_length then
86 | proposed_length
87 | else
88 | maximum_length
89 | else
90 | minimum_length
91 | in
92 | { left = ( int_width - length ) // 2
93 | , top = ( int_height - length ) // 2
94 | , length = length
95 | , minimum_length = minimum_length
96 | , maximum_length = maximum_length
97 | , image_width = int_width
98 | , image_height = int_height
99 | , natural_width = int_natural_width
100 | , natural_height = int_natural_height
101 | , rectangle_state = AtRest
102 | }
103 |
104 |
105 |
106 | -- VIEW
107 |
108 |
109 | {-| The view of an image that can be cropped.
110 | -}
111 | view : String -> Maybe Model -> Html Msg
112 | view url maybe_settings =
113 | div
114 | [ class "elm-crop-image"
115 | , style "position" "relative"
116 | ]
117 | [ img
118 | [ src url
119 | , style "max-width" "100%"
120 | , onLoad GotImageSize
121 | , Html.id "elm-image-crop--img"
122 | ]
123 | []
124 | , case maybe_settings of
125 | Just settings -> viewCropRectangle settings
126 | Nothing -> text ""
127 | ]
128 |
129 |
130 | viewCropRectangle : Model -> Html Msg
131 | viewCropRectangle settings =
132 | let
133 | width = settings.image_width
134 |
135 | height = settings.image_height
136 |
137 | length = settings.length
138 |
139 | widthStr = (String.fromInt width)
140 |
141 | heightStr = (String.fromInt height)
142 |
143 | cut_out =
144 | [ ( 0, settings.image_height )
145 | , ( width, 0)
146 | , ( 0, -height)
147 | , ( -width, 0) -- we should now be at 0, 0)
148 | , ( settings.left, settings.top )
149 | , ( length, 0 )
150 | , ( 0, length )
151 | , ( -length, 0 )
152 | , ( 0, -length )
153 | , ( -settings.left, -settings.top )
154 | ]
155 |
156 | rectangle =
157 | [ ( 0, 0 )
158 | , ( length, 0 )
159 | , ( 0, length )
160 | , ( -length, 0 )
161 | ]
162 |
163 | offset = 7
164 |
165 | resize_marker_length = 15
166 |
167 | corner_length = offset + resize_marker_length + 10
168 |
169 | -- Make corner a bit bigger than the marker, makes it easier to grab
170 | corner =
171 | [ ( 0, 0 )
172 | , ( corner_length, 0 )
173 | , ( 0, corner_length )
174 | , ( -corner_length, 0 )
175 | ]
176 |
177 | topLeftCorner =
178 | [ ( offset, offset + resize_marker_length )
179 | , ( offset, offset )
180 | , ( offset + resize_marker_length, offset )
181 | ]
182 |
183 | border_color = "#eee"
184 |
185 | wind_direction move_direction =
186 | case move_direction of
187 | MoveTopLeft -> "nw"
188 | MoveTopRight -> "ne"
189 | MoveBottomLeft -> "sw"
190 | MoveBottomRight -> "se"
191 |
192 | draggable_corner move_direction =
193 | svg
194 | [ Svg.Attributes.class "corner"
195 | , Svg.Attributes.x "0"
196 | , Svg.Attributes.y "0"
197 | , Svg.Attributes.width ( String.fromInt corner_length )
198 | , Svg.Attributes.height ( String.fromInt corner_length )
199 | , Svg.Attributes.style ( "cursor: " ++ (wind_direction move_direction) ++ "-resize" )
200 | , onMouseDown ( StartResize move_direction )
201 | , Touch.onStart ( StartResizeByTouch move_direction )
202 | , onMouseMove RectangleResized
203 | --, Touch.onMove RectangleResizedByTouch
204 | , Touch.onMove ( uncurry RectangleResized << touchCoordinates )
205 | ]
206 | [ path
207 | [ d (pathToString corner )
208 | , stroke "transparent"
209 | ]
210 | []
211 | , polyline
212 | [ points ( pointsToString topLeftCorner )
213 | ]
214 | []
215 | ]
216 |
217 | corner_translation move_direction =
218 | case move_direction of
219 | MoveTopLeft -> (0, 0)
220 | MoveTopRight -> (settings.length, 0)
221 | MoveBottomLeft -> (0, settings.length)
222 | MoveBottomRight -> (settings.length, settings.length)
223 |
224 | rotation move_direction =
225 | case move_direction of
226 | MoveTopLeft -> 0
227 | MoveTopRight -> 90
228 | MoveBottomLeft -> 270
229 | MoveBottomRight -> 180
230 |
231 | transform_corner move_direction =
232 | let
233 | translation =
234 | corner_translation move_direction
235 | |> (\(x, y) -> (String.fromInt x) ++ ", " ++ (String.fromInt y) )
236 | in
237 | transform ( String.concat ["translate(", translation, "), rotate(", String.fromInt (rotation move_direction), ")" ] )
238 |
239 | positioned_corner move_direction =
240 | g
241 | [ transform_corner move_direction ]
242 | [ draggable_corner move_direction ]
243 |
244 | in
245 | Svg.svg
246 | [ Svg.Attributes.id "elm-image-crop--svg-overlay"
247 | , Svg.Attributes.width (widthStr ++ "px")
248 | , Svg.Attributes.height (heightStr ++ "px")
249 | , viewBox ( "0 0 " ++ widthStr ++ " " ++ heightStr )
250 | , Svg.Attributes.style "position: absolute; z-index: 1; top: 0; left: 0;"
251 |
252 | -- We'll capture the mouse move here too in case the mouse moves
253 | -- outside the rectange, which can easily happen and it's annoying
254 | -- to have the move and resize stop then
255 | , case settings.rectangle_state of
256 | AtRest -> nothing
257 | Moving _ _ _ _ -> onMouseMove RectangleMoved
258 | Resizing _ _ _ _ _ _ -> onMouseMove RectangleResized
259 | Pinching _ _ _ _ _ -> nothing
260 |
261 | , case settings.rectangle_state of
262 | AtRest -> nothing
263 | Moving _ _ _ _ -> Touch.onMove RectangleMovedByTouch
264 | Resizing _ _ _ _ _ _ -> nothing
265 | Pinching _ _ _ _ _ -> Touch.onMove RectangleResizedByPinch
266 | , onMouseUp BeAtRest
267 | , on "touchend" ( Json.succeed BeAtRest )
268 | , on "touchend" ( Json.succeed BeAtRest )
269 | -- TODO: should only stop move when there's a mouseout of this element,
270 | -- not any bubbled element
271 | , on "mouseleave" (Json.map PossiblyStopMove targetId)
272 | ]
273 | [ path
274 | [ d ( pathToString cut_out )
275 | , fill "black"
276 | , opacity "0.55"
277 | ]
278 | []
279 | , svg
280 | [ Svg.Attributes.x (String.fromInt settings.left)
281 | , Svg.Attributes.y (String.fromInt settings.top)
282 | , Svg.Attributes.width (String.fromInt settings.length)
283 | , Svg.Attributes.height (String.fromInt settings.length)
284 | , stroke border_color
285 | , fill "transparent"
286 | --, Svg.Attributes.class "no-text-select"
287 | ]
288 | [ path
289 | [ d ( pathToString rectangle )
290 | , Svg.Attributes.style "cursor: grab"
291 | , onMouseDown StartMove
292 | , Touch.onStart StartMoveOrResize
293 | , id "elm-imagecrop-cropped-image"
294 | ]
295 | []
296 | , lazy positioned_corner MoveTopLeft
297 | , lazy positioned_corner MoveTopRight
298 | , lazy positioned_corner MoveBottomLeft
299 | , lazy positioned_corner MoveBottomRight
300 | ]
301 | ]
302 |
303 |
304 | nothing : Svg.Attribute Msg
305 | nothing =
306 | Svg.Attributes.attributeName ""
307 |
308 |
309 | {-| path to string using relative coordinates.
310 | -}
311 | pathToString : List ( Int, Int ) -> String
312 | pathToString coordinates =
313 | let
314 | pathCommand index item =
315 | let
316 | ( x, y ) = item
317 |
318 | command =
319 | if index == 0 then
320 | "M"
321 | else
322 | " l"
323 | in
324 | command ++ String.fromInt x ++ " " ++ String.fromInt y
325 |
326 | strings =
327 | coordinates
328 | |> List.indexedMap pathCommand
329 | in
330 | String.concat strings ++ " Z"
331 |
332 |
333 | pointsToString : List ( Int, Int ) -> String
334 | pointsToString points =
335 | points
336 | |> List.map (\(x, y) -> (String.fromInt x) ++ "," ++ (String.fromInt y))
337 | |> String.join ","
338 |
339 |
340 | -- EVENT HANDLERS
341 |
342 | {-| Image dimensions are only available after browser has loaded the
343 | image.
344 | -}
345 | onLoad : (Float -> Float -> Float -> Float -> msg) -> Attribute msg
346 | onLoad tagger =
347 | on "load" (Json.map4 tagger imageWidth imageHeight naturalWidth naturalHeight)
348 |
349 |
350 | targetId : Json.Decoder String
351 | targetId =
352 | Json.at ["target", "id"] Json.string
353 |
354 |
355 | imageWidth : Json.Decoder Float
356 | imageWidth =
357 | Json.at ["target", "width" ] Json.float
358 |
359 |
360 | imageHeight : Json.Decoder Float
361 | imageHeight =
362 | Json.at ["target", "height" ] Json.float
363 |
364 |
365 | naturalWidth : Json.Decoder Float
366 | naturalWidth =
367 | Json.at ["target", "naturalWidth" ] Json.float
368 |
369 |
370 | naturalHeight : Json.Decoder Float
371 | naturalHeight =
372 | Json.at ["target", "naturalHeight" ] Json.float
373 |
374 |
375 | onMouseDown : ( Int -> Int -> msg) -> Attribute msg
376 | onMouseDown tagger =
377 | Svg.Events.on "mousedown" (Json.map2 tagger clientX clientY )
378 |
379 |
380 | onMouseMove : ( Int -> Int -> msg) -> Attribute msg
381 | onMouseMove tagger =
382 | Svg.Events.on "mousemove" (Json.map2 tagger clientX clientY )
383 |
384 |
385 | clientX : Json.Decoder Int
386 | clientX =
387 | Json.field "clientX" Json.int
388 |
389 |
390 | clientY : Json.Decoder Int
391 | clientY =
392 | Json.field "clientY" Json.int
393 |
394 |
395 | touchCoordinates : Touch.Event -> ( Int, Int )
396 | touchCoordinates touchEvent =
397 | let
398 | ( x, y ) =
399 | List.head touchEvent.changedTouches
400 | |> Maybe.map .clientPos
401 | |> Maybe.withDefault ( 0, 0 )
402 | in
403 | ( round x, round y )
404 |
405 | {- Copied from Elm.Basics 0.18
406 | -}
407 | uncurry : (a -> b -> c) -> (a,b) -> c
408 | uncurry f (a,b) =
409 | f a b
410 |
411 |
412 | -- UPDATE
413 |
414 | {-| Opaque type for the messages this component uses.
415 | -}
416 | type Msg
417 | = GotImageSize Float Float Float Float
418 | | StartMove Int Int
419 | | PossiblyStopMove String
420 | | BeAtRest
421 | | RectangleMoved Int Int
422 | | StartResize MoveEdge Int Int
423 | | RectangleResized Int Int
424 | | StartMoveOrResize Touch.Event
425 | | StartResizeByTouch MoveEdge Touch.Event
426 | | RectangleMovedByTouch Touch.Event
427 | | RectangleResizedByTouch Touch.Event
428 | | RectangleResizedByPinch Touch.Event
429 |
430 |
431 | {-| Handle the commands.
432 | -}
433 | update : Msg -> Maybe Model -> ( Maybe Model, Cmd Msg )
434 | update msg maybe_model =
435 | case maybe_model of
436 | Nothing ->
437 | case msg of
438 | GotImageSize width height natural_width natural_height ->
439 | ( Just ( initialModel width height natural_width natural_height ), Cmd.none )
440 | _ ->
441 | ( Nothing, Cmd.none )
442 | Just model ->
443 | case msg of
444 | GotImageSize _ _ _ _ ->
445 | ( maybe_model, Cmd.none )
446 |
447 | StartMove clientx clienty ->
448 | ( Just { model | rectangle_state = Moving model.left model.top clientx clienty }, Cmd.none )
449 |
450 | BeAtRest ->
451 | ( Just { model | rectangle_state = AtRest }, Cmd.none )
452 |
453 | PossiblyStopMove id ->
454 | if id == "elm-image-crop--svg-overlay" then
455 | ( Just { model | rectangle_state = AtRest }, Cmd.none )
456 | else
457 | ( maybe_model, Cmd.none )
458 |
459 | RectangleMoved clientx clienty ->
460 | case model.rectangle_state of
461 | Moving originalx originaly startx starty ->
462 | let
463 | proposed_left = originalx + clientx - startx
464 | proposed_top = originaly + clienty - starty
465 | left =
466 | if proposed_left < 0 then
467 | 0
468 | else
469 | if proposed_left + model.length >= model.image_width then
470 | model.image_width - model.length
471 | else
472 | proposed_left
473 | top =
474 | if proposed_top < 0 then
475 | 0
476 | else
477 | if proposed_top + model.length >= model.image_height then
478 | model.image_height - model.length
479 | else
480 | proposed_top
481 | in
482 | ( Just { model | left = left, top = top }, Cmd.none )
483 | _ ->
484 | ( maybe_model, Cmd.none )
485 |
486 | StartResize edge clientx clienty ->
487 | ( Just { model | rectangle_state = Resizing edge model.left model.top model.length clientx clienty }, Cmd.none )
488 |
489 | RectangleResized clientx clienty ->
490 | case model.rectangle_state of
491 | Resizing corner original_left original_top original_length startx starty ->
492 | updateRectangleResizedByCorners maybe_model model corner original_left original_top original_length startx starty clientx clienty
493 | _ ->
494 | ( maybe_model, Cmd.none )
495 |
496 | StartMoveOrResize event ->
497 | let
498 | rest = ( Just { model | rectangle_state = AtRest }, Cmd.none )
499 | in
500 | case event.targetTouches of
501 | [] -> rest
502 | [ single_touch ] ->
503 | let
504 | ( clientx, clienty ) = single_touch.clientPos
505 | in
506 | update (StartMove (round clientx) (round clienty) ) maybe_model
507 | first_touch :: more_touches ->
508 | case more_touches of
509 | [] -> rest -- impossible case
510 | [ second_touch ] ->
511 | ( Just { model | rectangle_state = Pinching model.left model.top model.length first_touch.clientPos second_touch.clientPos }, Cmd.none )
512 | --( Just { model | rectangle_state = AtRest }, Cmd.none )
513 | _ :: _ -> rest -- multitouch, reset what we're doing
514 |
515 | RectangleMovedByTouch event ->
516 | let
517 | rest = ( Just { model | rectangle_state = AtRest }, Cmd.none )
518 | in
519 | case event.touches of
520 | [] -> rest
521 | [ single_touch ] ->
522 | let
523 | ( clientx, clienty ) = single_touch.clientPos
524 | in
525 | update (RectangleMoved (round clientx) (round clienty) ) maybe_model
526 | _ :: _ -> rest
527 |
528 | StartResizeByTouch edge event ->
529 | let
530 | rest = ( Just { model | rectangle_state = AtRest }, Cmd.none )
531 | in
532 | case event.targetTouches of
533 | [] -> rest
534 | [ single_touch ] ->
535 | let
536 | ( clientx, clienty ) = single_touch.clientPos
537 | in
538 | update (StartResize edge (round clientx) (round clienty) ) maybe_model
539 | _ :: _ -> rest
540 |
541 | RectangleResizedByTouch event ->
542 | case List.head event.changedTouches of
543 | Just touch ->
544 | let
545 | ( clientx, clienty ) = touch.clientPos
546 | in
547 | update (RectangleResized (round clientx) (round clienty) ) maybe_model
548 | Nothing ->
549 | ( maybe_model, Cmd.none )
550 |
551 | RectangleResizedByPinch event ->
552 | let
553 | impossible_case = ( maybe_model, Cmd.none )
554 | in
555 | case model.rectangle_state of
556 | Pinching original_left original_top original_length original_first_touch original_second_touch ->
557 | case event.targetTouches of
558 | [] -> impossible_case
559 | [ _ ] -> impossible_case
560 | first_touch :: more_touches ->
561 | case more_touches of
562 | [] -> impossible_case
563 | [ second_touch ] ->
564 | updateRectangleResizedByPinch model original_left original_top original_length original_first_touch original_second_touch first_touch second_touch
565 | _ :: _ -> impossible_case
566 | _ ->
567 | impossible_case
568 |
569 |
570 |
571 | {- Handle update when user resizes rectangle by dragging corners.
572 |
573 | We pass in `maybe_model` only for efficiency sake so Elm doesn't think
574 | model has changed, and therefore redraws.
575 | -}
576 | updateRectangleResizedByCorners maybe_model model corner original_left original_top original_length startx starty clientx clienty =
577 | let
578 | delta_x = clientx - startx
579 | delta_y = clienty - starty
580 |
581 | direction =
582 | if delta_x <= 0 then
583 | if delta_y <= 0 then
584 | NorthWest
585 | else
586 | SouthWest
587 | else
588 | if delta_y <= 0 then
589 | NorthEast
590 | else
591 | SouthEast
592 |
593 | -- We use 0 to cancel out forbidden directions
594 | sign =
595 | case ( corner, direction ) of
596 | ( MoveTopLeft, NorthWest ) -> 1
597 | ( MoveTopLeft, SouthEast ) -> -1
598 | ( MoveTopRight, NorthEast ) -> 1
599 | ( MoveTopRight, SouthWest ) -> -1
600 | ( MoveBottomLeft, SouthWest ) -> 1
601 | ( MoveBottomLeft, NorthEast ) -> -1
602 | ( MoveBottomRight, SouthEast ) -> 1
603 | ( MoveBottomRight, NorthWest ) -> -1
604 | ( _, _ ) -> 0
605 |
606 | allowed_move = sign /= 0
607 |
608 | -- Using the actual distance doesn't work, as it grows faster then the drag
609 | d = sign * (min (abs delta_x) (abs delta_y))
610 |
611 | proposed_delta =
612 | if original_length + d >= model.minimum_length then
613 | d
614 | else
615 | model.minimum_length - original_length
616 |
617 | -- Cap delta so we don't move outside image
618 | delta =
619 | case corner of
620 | MoveTopLeft ->
621 | if original_left - proposed_delta < 0 || original_top - proposed_delta < 0 then
622 | min original_left original_top
623 | else
624 | proposed_delta
625 | MoveTopRight ->
626 | if original_top - proposed_delta < 0 || original_left + original_length + proposed_delta > model.image_width then
627 | min original_top (model.image_width - original_left - original_length)
628 | else
629 | proposed_delta
630 | MoveBottomLeft ->
631 | if original_left - proposed_delta < 0 || original_top + proposed_delta > model.image_height then
632 | min original_left (model.image_height - original_top)
633 | else
634 | proposed_delta
635 | MoveBottomRight ->
636 | if original_left + original_length + proposed_delta > model.image_width || original_top + original_length + proposed_delta > model.image_height then
637 | min (model.image_width - original_left - original_length) (model.image_height - original_top - original_length)
638 | else
639 | proposed_delta
640 |
641 | new_length = original_length + delta
642 |
643 | ( new_left, new_top ) =
644 | case corner of
645 | MoveTopLeft ->
646 | ( original_left - delta, original_top - delta )
647 | MoveTopRight ->
648 | ( original_left, original_top - delta )
649 | MoveBottomLeft ->
650 | ( original_left - delta, original_top )
651 | MoveBottomRight ->
652 | ( original_left, original_top )
653 |
654 | in
655 | if allowed_move && delta /= 0 then
656 | ( Just { model | left = new_left, top = new_top, length = new_length }, Cmd.none )
657 | else
658 | ( maybe_model, Cmd.none )
659 |
660 |
661 | {- Handle update for when user resizes the rectangle by pinching.
662 | -}
663 | updateRectangleResizedByPinch model original_left original_top original_length original_first_touch original_second_touch first_touch second_touch =
664 | let
665 |
666 | original_distance = distance original_first_touch original_second_touch
667 |
668 | pinch_distance = distance first_touch.clientPos second_touch.clientPos
669 | proposed_delta = pinch_distance - original_distance
670 |
671 | delta =
672 | if original_length + proposed_delta >= model.minimum_length then
673 | proposed_delta
674 | else
675 | model.minimum_length
676 |
677 | -- Calculate by how much the left edge should move if the user had moved the rectangle.
678 | -- Note that this number is negative when the rectangle gets bigger.
679 | proposed_left_delta = round (min (Tuple.first first_touch.clientPos - Tuple.first original_first_touch) (Tuple.first second_touch.clientPos - Tuple.first original_second_touch))
680 |
681 | proposed_top_delta = round (min (Tuple.second first_touch.clientPos - Tuple.second original_first_touch) (Tuple.second second_touch.clientPos - Tuple.second original_second_touch))
682 |
683 | -- How much should the left edge move if the user didn't move the triangle?
684 | -- Note that this number is positive when the rectangle gets bigger.
685 | position_delta_without_move = round (toFloat delta / 2)
686 |
687 | -- Move the left and top edges out by only that bit which the user didn't move
688 | left_delta =
689 | -position_delta_without_move + proposed_left_delta
690 |
691 | top_delta =
692 | -position_delta_without_move + proposed_top_delta
693 |
694 | -- Make sure new_length never exceeds the maximum
695 | new_length =
696 | clamp model.minimum_length model.maximum_length (original_length + delta)
697 |
698 | -- If left bumps into left edge, don't go past it, but grow to the right, and vice versa
699 | new_left =
700 | if original_left + left_delta >= 0 then
701 | if original_left + left_delta + new_length <= model.image_width then
702 | original_left + left_delta
703 | else
704 | model.image_width - new_length
705 | else
706 | 0
707 |
708 | -- Same for top, make sure rectangle stays inside image.
709 | -- If image has reached max width we get a bit of a weird
710 | -- effect that the top moves upward, without the user giving a
711 | -- clear pinch move upward.
712 | new_top =
713 | if original_top + top_delta >= 0 then
714 | if original_top + top_delta + new_length <= model.image_height then
715 | original_top + top_delta
716 | else
717 | model.image_height - new_length
718 | else
719 | 0
720 |
721 | in
722 | ( Just { model | length = new_length, left = new_left, top = new_top }, Cmd.none )
723 |
724 |
725 |
726 | {- Calculate distance between two points.
727 |
728 | See: https://www.wikihow.com/Find-the-Distance-Between-Two-Points
729 | -}
730 | distance : ( Float, Float ) -> ( Float, Float ) -> Int
731 | distance ( x1, y1 ) ( x2, y2 ) =
732 | round ( sqrt ( (x2 - x1)^2 + (y2 - y1)^2 ) )
733 |
734 |
735 | distance_int : Int -> Int -> Int -> Int -> Int
736 | distance_int x1 y1 x2 y2 =
737 | round ( sqrt (toFloat ( (x2 - x1)^2 + (y2 - y1)^2 ) ) )
738 |
--------------------------------------------------------------------------------