desck 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Main.elm ADDED
@@ -0,0 +1,3743 @@
1
+ port module Main exposing (main)
2
+
3
+ import Browser
4
+ import Card exposing (Card, makeCard)
5
+ import Deck exposing (modelUsing, parseDeck)
6
+ import DeckView exposing (backface, cardSize, cornerRadius, frontface, includes)
7
+ import Draggable
8
+ import Draggable.Events exposing (onDragBy, onDragEnd)
9
+ import File exposing (File)
10
+ import File.Select as Select
11
+ import Html as H exposing (Html)
12
+ import Html.Attributes as HA exposing (style)
13
+ import Html.Events as E
14
+ import Json.Decode as Decode
15
+ import Json.Encode exposing (Value, encode, list, string)
16
+ import Layout exposing (Spread(..), defaultSpread, spreadAsString, updateSpreadValue, validateSpreadValue)
17
+ import Markdown
18
+ import Palette exposing (Color(..), Palette, colorAsString, colorFromString, colorPicker, colorPickerWithTransparent, setPaletteColor)
19
+ import Project exposing (Project, Shape, Shapes(..), Zorder(..), arrowEnds, arrowPoints, arrowPointsToPos, arrowTransform, getHighestShapeByPos, getLastId, getSelectedCardModels, getShapeById, mergeAll, positionCards, selectWithCovering, shapeId, shapeRect, shapeTransform, splitAll, stampRect)
20
+ import Rect exposing (..)
21
+ import SaveFile exposing (Settings, decodeSaveFile, encodePalette, encodeSaveFile)
22
+ import Svg exposing (Svg)
23
+ import Svg.Attributes as Attr
24
+ import Svg.Events exposing (onMouseUp)
25
+ import Svg.Keyed
26
+ import Task
27
+ import Theme
28
+ import Util exposing (..)
29
+ import Vec exposing (Vec2)
30
+ import Zone exposing (..)
31
+
32
+
33
+
34
+ -- INPUTS
35
+
36
+
37
+ port resizeMessage : (Vec2 -> msg) -> Sub msg
38
+
39
+
40
+ type alias InputMessage =
41
+ { kind : String
42
+ , data : String
43
+ }
44
+
45
+
46
+ port input : (InputMessage -> msg) -> Sub msg
47
+
48
+
49
+
50
+ -- OUTPUTS
51
+
52
+
53
+ port output : Value -> Cmd msg
54
+
55
+
56
+ type alias StartFlags =
57
+ { seed : Int
58
+ , offline : Bool
59
+ , window : Vec2
60
+ , initial : String
61
+ , help : String
62
+ , saveFile : Maybe String
63
+ }
64
+
65
+
66
+ dragState : Maybe Shape -> (Vec2 -> Vec2) -> DragEvent -> DragState
67
+ dragState shape toLocal event =
68
+ { shape = shape
69
+ , button = parseMouseButton event.button
70
+ , start = toLocal <| Vec.vec2 event.x event.y
71
+ , offset = Vec.zero
72
+ , selecting = False
73
+ }
74
+
75
+
76
+ type alias DragState =
77
+ { start : Vec2
78
+ , offset : Vec2
79
+ , shape : Maybe Shape
80
+ , button : MouseButton
81
+ , selecting : Bool
82
+
83
+ -- PLAN Editor - rotate selection around pivot : Maybe Vec2
84
+ }
85
+
86
+
87
+ type Format
88
+ = SVG
89
+ | PNG
90
+ | JSON
91
+ | Symbols
92
+
93
+
94
+ type Panel
95
+ = Help
96
+ | Settings
97
+ | ExportSettings
98
+
99
+
100
+
101
+ -- | IO
102
+
103
+
104
+ type SettingFlag
105
+ = EmbedSymbols
106
+ | StripData
107
+ | MapColors
108
+ | MapFont
109
+
110
+
111
+ type ToggleTarget
112
+ = Boxed
113
+ | BringForward
114
+ | StackSelect
115
+ | ShowPanel (Maybe Panel)
116
+ | Setting SettingFlag
117
+
118
+
119
+ type Layer
120
+ = Zones
121
+ | Cards
122
+ | Arrows
123
+ | Stamps
124
+
125
+
126
+ type alias Drag =
127
+ { state : DragState
128
+ , mode : DragMode
129
+ }
130
+
131
+
132
+ type Msg
133
+ = Noop String
134
+ -- PORTS
135
+ | Input InputMessage
136
+ | Resize Vec2
137
+ | SetKeyMods KeyMods
138
+ -- FILES
139
+ | FileSelected File
140
+ | FileLoaded String
141
+ | SaveProject
142
+ | ListFiles (List String)
143
+ | LoadProject String
144
+ | ChangeFilename
145
+ | TypeFilename String
146
+ | NewProject
147
+ | DuplicateProject
148
+ | Export Format
149
+ | UploadFile
150
+ -- DRAG & SELECT
151
+ | DragMsg (Draggable.Msg Id)
152
+ | StartDragWithPos Id (Draggable.Msg Id) DragEvent
153
+ | DragBy Vec2
154
+ | EndDrag
155
+ | Click MouseButton (Maybe Shape) Vec2 Bool
156
+ | FinishSelection Selection
157
+ | SelectArrow Id
158
+ | SelectAll
159
+ -- MODIFY
160
+ | Mod Project.Msg
161
+ | ModRandom Project.Msg
162
+ | Spawn
163
+ | SpawnStamp
164
+ | ApplySelector
165
+ | Remove
166
+ | SpreadByType Spread
167
+ | SpreadByValue String
168
+ | SpreadCards Id
169
+ | UpdateZoneString ZoneField String
170
+ | UpdateZoneRect Id RectCoordinate String
171
+ | SetArrowColor String
172
+ | SetStampColor Bool String
173
+ | SetStampLabel String
174
+ | SetStampSize Bool String
175
+ | SetArrowWidth String
176
+ | SetArrowHead String
177
+ -- TEXT
178
+ | UpdateFont FontType
179
+ | TypeFont FontType String
180
+ | TypeCommand String
181
+ | TypeStamp String
182
+ | TypeSelector String
183
+ -- EDITOR
184
+ | ToggleLaterMask Layer
185
+ | Toggle ToggleTarget
186
+ | Undo
187
+ | UpdatePaletteColor Color String
188
+ | UpdatePalette Palette
189
+ | UpdateHeightPixels String
190
+
191
+
192
+
193
+ -- | UpdateCardField Id CardField String
194
+ -- | OrderSelected SortType
195
+ -- | UpdateSnap String
196
+ -- | UpdateColorPicker ColorPicker.Msg
197
+ -- | UpdatePaletteString String
198
+ -- | OpenColorPicker Color C.Color
199
+
200
+
201
+ type alias LayerMask =
202
+ { cards : Bool
203
+ , zones : Bool
204
+ , arrows : Bool
205
+ , stamps : Bool
206
+ }
207
+
208
+
209
+ type alias Model =
210
+ { flags : StartFlags
211
+ , debug : Bool
212
+
213
+ -- Files
214
+ , files : List String
215
+ , nameInput : String
216
+ , fileState : FileState
217
+
218
+ -- Editor Settings
219
+ , unlockedLayers : LayerMask
220
+ , palette : Palette
221
+ , moveToTop : Bool
222
+ , stackSelect : Bool
223
+ , boxed : Bool
224
+ , zonePreset : Zone
225
+ , arrowPreset : Arrow
226
+ , stampPreset : Stamp
227
+
228
+ -- Editor State
229
+ , mods : KeyMods
230
+ , window : Vec2
231
+ , command : String
232
+ , stampInput : String
233
+ , panel : Maybe Panel
234
+ , undos : List Value
235
+ , id : Int
236
+ , selected : List Id
237
+ , selectedCardModels : String
238
+ , drag : Draggable.State Id
239
+ , dragState : Maybe Drag
240
+
241
+ -- Project
242
+ , spreadType : Spread
243
+ , spreadValue : Int
244
+ , project : Project
245
+ , name : String
246
+ , settings : Settings
247
+ }
248
+
249
+
250
+
251
+ -- TODO Help - make a tutorial template
252
+
253
+
254
+ init : StartFlags -> ( Model, Cmd Msg )
255
+ init flags =
256
+ let
257
+ model : Model
258
+ model =
259
+ { flags = flags
260
+ , debug = False
261
+ , files = []
262
+ , nameInput = flags.initial
263
+ , fileState = New
264
+
265
+ -- settings
266
+ , unlockedLayers = { cards = True, zones = True, arrows = True, stamps = True }
267
+ , palette = Theme.default
268
+ , moveToTop = False
269
+ , stackSelect = False
270
+ , boxed = True
271
+ , zonePreset = defaultZone
272
+ , arrowPreset = defaultArrow
273
+ , stampPreset = defaultStamp
274
+
275
+ -- state
276
+ , mods = { ctrl = False, shift = False, alt = False }
277
+ , window = flags.window
278
+ , command = ""
279
+ , stampInput = ""
280
+ , panel = Nothing
281
+ , undos = []
282
+ , id = 0
283
+ , selected = []
284
+ , selectedCardModels = ""
285
+ , drag = Draggable.init
286
+ , dragState = Nothing
287
+
288
+ -- project
289
+ , spreadType = Horizontal
290
+ , spreadValue = defaultSpread Horizontal
291
+ , project = Project.empty
292
+ , name = flags.initial
293
+ , settings = SaveFile.default_settings
294
+ }
295
+ in
296
+ case flags.saveFile of
297
+ Just s ->
298
+ let
299
+ ( m, cmds ) =
300
+ decodeSaveFileIntoModel False
301
+ model
302
+ s
303
+ (\m_ ->
304
+ Cmd.batch []
305
+ )
306
+ in
307
+ ( m
308
+ , cmds
309
+ )
310
+
311
+ Nothing ->
312
+ ( model
313
+ , Cmd.batch <|
314
+ [ outputString LoadPalette <| Json.Encode.encode 0 <| encodePalette Theme.default
315
+ , outputString GetSize ""
316
+ , outputString Import flags.initial
317
+ ]
318
+ )
319
+
320
+
321
+ subscriptions : Model -> Sub Msg
322
+ subscriptions model =
323
+ Sub.batch
324
+ [ Draggable.subscriptions DragMsg model.drag
325
+ , resizeMessage Resize
326
+ , input Input
327
+ ]
328
+
329
+
330
+ main : Program StartFlags Model Msg
331
+ main =
332
+ Browser.element
333
+ { init = init
334
+ , update = update
335
+ , subscriptions = subscriptions
336
+ , view = view
337
+ }
338
+
339
+
340
+ layerString : Layer -> String
341
+ layerString t =
342
+ case t of
343
+ Cards ->
344
+ "Cards"
345
+
346
+ Zones ->
347
+ "Zones"
348
+
349
+ Arrows ->
350
+ "Arrows"
351
+
352
+ Stamps ->
353
+ "Stamps"
354
+
355
+
356
+ type alias KeyMods =
357
+ { ctrl : Bool
358
+ , shift : Bool
359
+ , alt : Bool
360
+ }
361
+
362
+
363
+ type alias Selection =
364
+ { start : Vec2
365
+ , end : Vec2
366
+ }
367
+
368
+
369
+ type DragMode
370
+ = Move
371
+ | Connect
372
+ | Select
373
+ | CreateZone
374
+ | Turn
375
+ | Pan
376
+
377
+
378
+ type FileState
379
+ = New
380
+ | NewModified
381
+ | Loading
382
+ | Opened
383
+ | Modified
384
+
385
+
386
+ partitionSelected : Model -> ( List Card, List Card )
387
+ partitionSelected model =
388
+ List.partition (\c -> List.member c.id model.selected) model.project.cards
389
+
390
+
391
+
392
+ -- FEAT snap to grid, card, zone
393
+ -- dragSelectedBy : Vec2 -> Model -> Model
394
+ -- dragSelectedBy delta model =
395
+ -- let
396
+ -- ( dragged, idle ) =
397
+ -- partitionSelected model
398
+ -- dragCard c =
399
+ -- { c | pos = c.pos |> Vec.add delta }
400
+ -- cards =
401
+ -- if model.bringForward then
402
+ -- List.concat
403
+ -- [ List.map
404
+ -- dragCard
405
+ -- dragged
406
+ -- , idle
407
+ -- ]
408
+ -- else
409
+ -- model.cards
410
+ -- |> List.map
411
+ -- (\c ->
412
+ -- if List.member c.id model.selected then
413
+ -- dragCard c
414
+ -- else
415
+ -- c
416
+ -- )
417
+ -- zones =
418
+ -- model.zones
419
+ -- |> List.map
420
+ -- (\z ->
421
+ -- if List.member z.id model.selected then
422
+ -- { z | rect = { pos = Vec.add delta z.rect.pos, size = z.rect.size } }
423
+ -- else
424
+ -- z
425
+ -- )
426
+ -- in
427
+ -- { model | cards = cards, zones = zones }
428
+
429
+
430
+ dragConfig : Draggable.Config Id Msg
431
+ dragConfig =
432
+ Draggable.customConfig
433
+ [ onDragBy (\( dx, dy ) -> Vec2 dx dy |> DragBy)
434
+
435
+ -- , onDragStart StartDrag
436
+ , onDragEnd EndDrag
437
+
438
+ -- , onClick ClickTarget
439
+ -- , onMouseDown MouseDown
440
+ ]
441
+
442
+
443
+ getContained : Project -> Rect -> List Shape
444
+ getContained p r =
445
+ mergeAll p
446
+ |> List.filter
447
+ (\( _, shape ) ->
448
+ case shape of
449
+ Project.Arrow a ->
450
+ let
451
+ ( from, to ) =
452
+ arrowEnds p a
453
+ in
454
+ Rect.hasPoint from r && Rect.hasPoint to r
455
+
456
+ _ ->
457
+ let
458
+ ( rect, _, _ ) =
459
+ shapeTransform p shape
460
+ in
461
+ Rect.hasRect r rect
462
+ )
463
+ |> List.map Tuple.second
464
+
465
+
466
+
467
+ -- toggleAct : CardAction -> Model -> List Card
468
+ -- toggleAct action model =
469
+ -- let
470
+ -- { selected, cards } =
471
+ -- model
472
+ -- ( targets, rest ) =
473
+ -- partitionSelected model
474
+ -- center =
475
+ -- Vec.scale 0.5 Vec.one
476
+ -- act : Card -> Card -> Card
477
+ -- act h c =
478
+ -- case action of
479
+ -- Flip ->
480
+ -- { c | front = not h.front }
481
+ -- Rotate ->
482
+ -- if c.rot.angle == 0.0 then
483
+ -- { c | rot = { angle = 90.0, pivot = center } }
484
+ -- else
485
+ -- { c | rot = { angle = 0.0, pivot = center } }
486
+ -- Highlight ->
487
+ -- { c | highlight = not h.highlight }
488
+ -- Erase ->
489
+ -- c
490
+ -- head =
491
+ -- List.head selected
492
+ -- |> Maybe.andThen
493
+ -- (\n -> List.head <| List.filter (\c -> n == c.id) targets)
494
+ -- in
495
+ -- case head of
496
+ -- Nothing ->
497
+ -- cards
498
+ -- Just h ->
499
+ -- if model.bringForward then
500
+ -- List.concat
501
+ -- [ targets |> List.map (act h)
502
+ -- , rest
503
+ -- ]
504
+ -- else
505
+ -- List.map
506
+ -- (\c ->
507
+ -- if List.member c.id model.selected then
508
+ -- act h c
509
+ -- else
510
+ -- c
511
+ -- )
512
+ -- model.cards
513
+
514
+
515
+ serializeModel : Model -> Value
516
+ serializeModel model =
517
+ { name = model.name
518
+ , project = model.project
519
+ , palette = model.palette
520
+ , settings = model.settings
521
+ }
522
+ |> encodeSaveFile
523
+
524
+
525
+ stringifyPalette : Palette -> String
526
+ stringifyPalette p =
527
+ encodePalette p
528
+ |> Json.Encode.encode 0
529
+
530
+
531
+ stringifyModel : Model -> String
532
+ stringifyModel model =
533
+ serializeModel model
534
+ |> Json.Encode.encode 0
535
+
536
+
537
+ type OutputType
538
+ = ExportAs Format
539
+ | Save
540
+ | Fail
541
+ | Success
542
+ | Import
543
+ | GetSize
544
+ | LoadPalette
545
+ | LoadFont FontType
546
+ | LoadFile
547
+
548
+
549
+ outputKindAsString : OutputType -> String
550
+ outputKindAsString kind =
551
+ case kind of
552
+ ExportAs format ->
553
+ case format of
554
+ PNG ->
555
+ "export-png"
556
+
557
+ SVG ->
558
+ "export-svg"
559
+
560
+ JSON ->
561
+ "export-json"
562
+
563
+ Symbols ->
564
+ "export-symbols"
565
+
566
+ Save ->
567
+ "save"
568
+
569
+ Fail ->
570
+ "fail"
571
+
572
+ Success ->
573
+ "success"
574
+
575
+ Import ->
576
+ "import"
577
+
578
+ LoadPalette ->
579
+ "load-palette"
580
+
581
+ LoadFont ft ->
582
+ case ft of
583
+ PrimaryFont ->
584
+ "load-primary-font"
585
+
586
+ SecondaryFont ->
587
+ "load-secondary-font"
588
+
589
+ LoadFile ->
590
+ "load-file"
591
+
592
+ GetSize ->
593
+ "get-size"
594
+
595
+
596
+ outputData : OutputType -> Value -> Cmd msg
597
+ outputData kind data =
598
+ Json.Encode.object
599
+ [ ( "kind", Json.Encode.string <| outputKindAsString kind )
600
+ , ( "data", data )
601
+ ]
602
+ |> output
603
+
604
+
605
+ outputString : OutputType -> String -> Cmd msg
606
+ outputString kind s =
607
+ Json.Encode.object
608
+ [ ( "kind", Json.Encode.string <| outputKindAsString kind )
609
+ , ( "data", Json.Encode.string s )
610
+ ]
611
+ |> output
612
+
613
+
614
+ type InputKind
615
+ = Open
616
+ | Files
617
+ | Saved
618
+ | Exported
619
+ | Mods
620
+
621
+
622
+ inputKindFromString : String -> Maybe InputKind
623
+ inputKindFromString s =
624
+ case s of
625
+ "open" ->
626
+ Just Open
627
+
628
+ "files" ->
629
+ Just Files
630
+
631
+ "saved" ->
632
+ Just Saved
633
+
634
+ "exported" ->
635
+ Just Exported
636
+
637
+ "mods" ->
638
+ Just Mods
639
+
640
+ _ ->
641
+ Nothing
642
+
643
+
644
+ decodeSaveFileIntoClearedModel : Model -> String -> (Model -> Cmd Msg) -> ( Model, Cmd Msg )
645
+ decodeSaveFileIntoClearedModel =
646
+ decodeSaveFileIntoModel True
647
+
648
+
649
+ decodeSaveFileIntoModel : Bool -> Model -> String -> (Model -> Cmd Msg) -> ( Model, Cmd Msg )
650
+ decodeSaveFileIntoModel clear model data success =
651
+ let
652
+ decoded =
653
+ decodeSaveFile data
654
+ in
655
+ case decoded of
656
+ Ok p ->
657
+ let
658
+ cleared =
659
+ if clear then
660
+ clearProject model p.name
661
+
662
+ else
663
+ model
664
+
665
+ zonePreset =
666
+ p.project.zones |> List.reverse |> List.head |> Maybe.withDefault model.zonePreset
667
+ in
668
+ ( { cleared
669
+ | fileState = Opened
670
+ , undos = model.undos
671
+ , window = model.window
672
+ , palette = p.palette
673
+ , settings = p.settings
674
+ , id = getLastId p.project
675
+ , project = p.project
676
+ , zonePreset =
677
+ { zonePreset | rect = { pos = Vec.zero, size = cardSize }, rot = { pivot = Vec.half Vec.one, angle = 0.0 } }
678
+ , arrowPreset = p.project.arrows |> List.reverse |> List.head |> Maybe.withDefault model.arrowPreset
679
+
680
+ -- , paletteString = String.join " " p.palette
681
+ }
682
+ , Cmd.batch <|
683
+ List.concat
684
+ [ [ success model ]
685
+ , case p.settings.primaryFont of
686
+ Just font ->
687
+ [ outputString (LoadFont PrimaryFont) font ]
688
+
689
+ Nothing ->
690
+ []
691
+ , case p.settings.secondaryFont of
692
+ Just font ->
693
+ [ outputString (LoadFont SecondaryFont) font ]
694
+
695
+ Nothing ->
696
+ []
697
+ , [ outputString LoadPalette <| stringifyPalette p.palette ]
698
+ ]
699
+ )
700
+
701
+ Err err ->
702
+ ( model, outputString Fail <| Decode.errorToString err )
703
+
704
+
705
+
706
+ -- decodeProjectIntoModel : Model -> String -> (Model -> Cmd Msg) -> ( Model, Cmd Msg )
707
+ -- decodeProjectIntoModel model data success =
708
+ -- decodeProjectIntoModelCleared True
709
+
710
+
711
+ processInput : Model -> InputMessage -> ( Model, Cmd Msg )
712
+ processInput model im =
713
+ let
714
+ n =
715
+ \m -> ( m, Cmd.none )
716
+ in
717
+ case inputKindFromString im.kind of
718
+ Nothing ->
719
+ n model
720
+
721
+ Just kind ->
722
+ case kind of
723
+ Open ->
724
+ update (FileLoaded im.data) model
725
+
726
+ Saved ->
727
+ n { model | fileState = Opened }
728
+
729
+ Mods ->
730
+ let
731
+ decoded =
732
+ im.data
733
+ |> Decode.decodeString
734
+ (Decode.map3 KeyMods
735
+ (Decode.field "ctrl" Decode.bool)
736
+ (Decode.field "shift" Decode.bool)
737
+ (Decode.field "alt" Decode.bool)
738
+ )
739
+ |> Result.withDefault { ctrl = False, shift = False, alt = False }
740
+ in
741
+ n { model | mods = decoded }
742
+
743
+ Files ->
744
+ let
745
+ decoded =
746
+ im.data
747
+ |> Decode.decodeString (Decode.list Decode.string)
748
+
749
+ files =
750
+ case decoded of
751
+ Ok list ->
752
+ list
753
+
754
+ Err _ ->
755
+ []
756
+ in
757
+ n { model | files = files }
758
+
759
+ Exported ->
760
+ n model
761
+
762
+
763
+ clearProject : Model -> String -> Model
764
+ clearProject model name =
765
+ let
766
+ ( initial, _ ) =
767
+ init model.flags
768
+ in
769
+ { initial
770
+ | files = model.files
771
+ , name = name
772
+ , nameInput = name
773
+ , fileState = New
774
+ }
775
+
776
+
777
+ makeArrow : Arrow -> Id -> Id -> Id -> Arrow
778
+ makeArrow preset from to id =
779
+ { preset
780
+ | from = from
781
+ , to = to
782
+ , id = id
783
+ }
784
+
785
+
786
+ selectInsideSelection : Model -> Selection -> Model
787
+ selectInsideSelection m s =
788
+ let
789
+ rect =
790
+ normalizeRect s.start s.end
791
+
792
+ selected =
793
+ getContained m.project rect
794
+ |> List.filter
795
+ (\shape ->
796
+ case shape of
797
+ Project.Card _ ->
798
+ m.unlockedLayers.cards
799
+
800
+ Project.Zone _ ->
801
+ m.unlockedLayers.zones
802
+
803
+ Project.Arrow _ ->
804
+ m.unlockedLayers.arrows
805
+
806
+ Project.Stamp _ ->
807
+ m.unlockedLayers.stamps
808
+ )
809
+ |> List.sortWith
810
+ (\a b ->
811
+ let
812
+ ( ar, _, _ ) =
813
+ shapeTransform m.project a
814
+
815
+ ( br, _, _ ) =
816
+ shapeTransform m.project b
817
+ in
818
+ case compare br.pos.x ar.pos.x of
819
+ EQ ->
820
+ compare br.pos.y ar.pos.y
821
+
822
+ o ->
823
+ o
824
+ )
825
+ |> List.map shapeId
826
+ in
827
+ { m | selected = selected, selectedCardModels = getSelectedCardModels m.project.cards selected }
828
+
829
+
830
+ toggleArrow : Model -> Maybe Shape -> Vec2 -> Maybe Model
831
+ toggleArrow m maybeShape pos =
832
+ case ( maybeShape, getHighestShapeByPos m.project pos ) of
833
+ ( Just fromShape, Just ( toId, toShape ) ) ->
834
+ let
835
+ fromId =
836
+ shapeId fromShape
837
+ in
838
+ case toShape of
839
+ Project.Arrow _ ->
840
+ Nothing
841
+
842
+ _ ->
843
+ if fromId == toId then
844
+ Nothing
845
+
846
+ else
847
+ case
848
+ m.project.arrows
849
+ |> List.filter (\a -> a.from == fromId && a.to == toId)
850
+ |> List.head
851
+ of
852
+ Just existing ->
853
+ Just
854
+ { m
855
+ | project =
856
+ Project.update (Project.Remove [ existing.id ]) m.project
857
+ , selected = m.selected |> List.filter (\s -> s /= existing.id)
858
+ }
859
+
860
+ Nothing ->
861
+ let
862
+ arrowId =
863
+ "a" ++ String.fromInt m.id
864
+ in
865
+ Just
866
+ { m
867
+ | project =
868
+ m.project
869
+ |> Project.update
870
+ (Project.Spawn
871
+ [ Project.Arrow <| makeArrow m.arrowPreset fromId toId arrowId ]
872
+ )
873
+ , selected =
874
+ [ arrowId ]
875
+ , id = m.id + 1
876
+ }
877
+
878
+ _ ->
879
+ Nothing
880
+
881
+
882
+ updateArrows : (Arrow -> Arrow) -> Model -> Model
883
+ updateArrows modify model =
884
+ let
885
+ project =
886
+ model.project
887
+ in
888
+ { model
889
+ | arrowPreset = modify model.arrowPreset
890
+ , project =
891
+ { project
892
+ | arrows =
893
+ List.map
894
+ (\a ->
895
+ if List.member a.id model.selected then
896
+ modify a
897
+
898
+ else
899
+ a
900
+ )
901
+ model.project.arrows
902
+ }
903
+ }
904
+
905
+
906
+ updateStamps : (Stamp -> Stamp) -> Model -> Model
907
+ updateStamps modify model =
908
+ let
909
+ project =
910
+ model.project
911
+ in
912
+ { model
913
+ | stampPreset = modify model.stampPreset
914
+ , project =
915
+ { project
916
+ | stamps =
917
+ List.map
918
+ (\a ->
919
+ if List.member a.id model.selected then
920
+ modify a
921
+
922
+ else
923
+ a
924
+ )
925
+ model.project.stamps
926
+ }
927
+ }
928
+
929
+
930
+ update : Msg -> Model -> ( Model, Cmd Msg )
931
+ update msg model =
932
+ let
933
+ settings =
934
+ model.settings
935
+
936
+ cards =
937
+ model.project.cards
938
+
939
+ n m =
940
+ ( m, Cmd.none )
941
+
942
+ save m =
943
+ update SaveProject m
944
+
945
+ withUndo m =
946
+ { m
947
+ | fileState =
948
+ if List.member m.fileState [ New, NewModified ] then
949
+ NewModified
950
+
951
+ else
952
+ Modified
953
+ , undos = serializeModel model :: m.undos
954
+ }
955
+
956
+ undoable m =
957
+ withUndo m
958
+ |> save
959
+
960
+ -- undoableDesk m =
961
+ -- ( withUndo m
962
+ -- , Task.attempt (\_ -> Noop <| "couldn't focus element") (Browser.Dom.focus "#view")
963
+ -- )
964
+ rect =
965
+ fitToCenter
966
+ model.project.viewport.size
967
+ (Vec2 model.window.x model.window.y)
968
+
969
+ toLocal p =
970
+ Vec.add
971
+ (Vec.scale
972
+ (model.project.viewport.size.x / rect.size.x)
973
+ (Vec.sub p rect.pos)
974
+ )
975
+ model.project.viewport.pos
976
+
977
+ processSelected m id =
978
+ case id of
979
+ "background" ->
980
+ []
981
+
982
+ _ ->
983
+ if m.mods.shift || m.stackSelect then
984
+ selectWithCovering model.project id
985
+ |> List.append
986
+ (if m.mods.ctrl then
987
+ m.selected
988
+
989
+ else
990
+ []
991
+ )
992
+ |> List.foldl
993
+ (\i ls ->
994
+ if List.member i ls then
995
+ ls
996
+
997
+ else
998
+ i :: ls
999
+ )
1000
+ []
1001
+
1002
+ else if m.mods.ctrl then
1003
+ if List.member id m.selected then
1004
+ m.selected
1005
+ -- List.filter (\i -> i /= id) m.selected
1006
+
1007
+ else
1008
+ id :: m.selected
1009
+
1010
+ else if List.member id m.selected then
1011
+ m.selected
1012
+
1013
+ else
1014
+ [ id ]
1015
+ in
1016
+ case msg of
1017
+ -- Noop s ->
1018
+ -- Debug.log s (n model)
1019
+ Noop _ ->
1020
+ n model
1021
+
1022
+ -- ⡤⠤⡀⡠⠤⡀⡤⠤⡀⠤⡤⠄
1023
+ -- ⡧⠤⠃⡇⠀⡇⡧⡤⠃⠀⡇⠀
1024
+ -- ⠃⠀⠀⠑⠒⠁⠃⠈⠂⠀⠃⠀
1025
+ SetKeyMods km ->
1026
+ n { model | mods = km }
1027
+
1028
+ Input im ->
1029
+ processInput model im
1030
+
1031
+ Resize ws ->
1032
+ n { model | window = ws }
1033
+
1034
+ -- ⡤⠤⠄⠠⡤⠀⡄⠀⠀⡤⠤⠄⡠⠤⡀
1035
+ -- ⡗⠒⠀⠀⡇⠀⡇⠀⠀⡗⠒⠀⠑⠒⡄
1036
+ -- ⠃⠀⠀⠐⠓⠀⠓⠒⠂⠓⠒⠂⠑⠒⠁
1037
+ FileSelected file ->
1038
+ ( model, Task.perform FileLoaded (File.toString file) )
1039
+
1040
+ FileLoaded s ->
1041
+ decodeSaveFileIntoClearedModel model s (\m -> outputString Success m.name)
1042
+
1043
+ TypeFilename f ->
1044
+ n { model | nameInput = f }
1045
+
1046
+ ChangeFilename ->
1047
+ let
1048
+ name =
1049
+ if List.member model.nameInput model.files then
1050
+ generateDuplicateName model.files model.nameInput
1051
+
1052
+ else
1053
+ model.nameInput
1054
+ in
1055
+ update SaveProject
1056
+ { model
1057
+ | name = name
1058
+ , nameInput = name
1059
+ }
1060
+
1061
+ ListFiles ls ->
1062
+ n { model | files = ls }
1063
+
1064
+ LoadProject f ->
1065
+ ( { model | fileState = Loading, name = f, nameInput = f }, outputString Import f )
1066
+
1067
+ SaveProject ->
1068
+ ( model, outputData Save (serializeModel model) )
1069
+
1070
+ NewProject ->
1071
+ n <| clearProject model "untitled"
1072
+
1073
+ DuplicateProject ->
1074
+ let
1075
+ name =
1076
+ generateDuplicateName model.files model.name
1077
+ in
1078
+ n { model | name = name, nameInput = name, fileState = New }
1079
+
1080
+ Export format ->
1081
+ let
1082
+ data =
1083
+ if format == JSON then
1084
+ stringifyModel model
1085
+
1086
+ else
1087
+ model.name
1088
+ in
1089
+ ( model, outputString (ExportAs format) data )
1090
+
1091
+ UploadFile ->
1092
+ ( model, Select.file [ "application/json" ] FileSelected )
1093
+
1094
+ -- ⡤⢄⠀⡤⠤⡀⡠⠤⡀⡠⠤⡀
1095
+ -- ⡇⠀⡇⡧⡤⠃⡧⠤⡇⡇⠤⡄
1096
+ -- ⠓⠊⠀⠃⠈⠂⠃⠀⠃⠑⠒⠁
1097
+ Click button shape _ selecting ->
1098
+ case shape of
1099
+ Nothing ->
1100
+ n { model | selected = [] }
1101
+
1102
+ Just s ->
1103
+ let
1104
+ updateIfCards fn =
1105
+ case s of
1106
+ Project.Card card ->
1107
+ undoable
1108
+ { model
1109
+ | project =
1110
+ Project.update
1111
+ (Project.UpdateShapes
1112
+ model.selected
1113
+ (\ss ->
1114
+ case ss of
1115
+ Project.Card c ->
1116
+ Project.Card <| fn card c
1117
+
1118
+ _ ->
1119
+ ss
1120
+ )
1121
+ )
1122
+ model.project
1123
+ }
1124
+
1125
+ _ ->
1126
+ n model
1127
+ in
1128
+ case button of
1129
+ RMB ->
1130
+ -- TODO UX - context menu to create/delete?
1131
+ if model.mods.alt then
1132
+ updateIfCards
1133
+ (\card c ->
1134
+ { c | highlight = not card.highlight }
1135
+ )
1136
+
1137
+ else if model.mods.shift then
1138
+ undoable { model | project = Project.update (Project.Remove model.selected) model.project }
1139
+
1140
+ else if model.mods.ctrl then
1141
+ updateIfCards
1142
+ (\card c ->
1143
+ { c | highlight = not card.highlight }
1144
+ )
1145
+
1146
+ else
1147
+ updateIfCards
1148
+ (\card c ->
1149
+ { c | front = not card.front }
1150
+ )
1151
+
1152
+ LMB ->
1153
+ if model.mods.alt then
1154
+ updateIfCards
1155
+ (\card c ->
1156
+ let
1157
+ angle =
1158
+ card.rot.angle
1159
+ in
1160
+ { c
1161
+ | rot =
1162
+ { angle =
1163
+ let
1164
+ a =
1165
+ round (angle / 90.0)
1166
+ |> modBy 4
1167
+ in
1168
+ toFloat ((a + 1) * 90)
1169
+ , pivot = Vec.half Vec.one
1170
+ }
1171
+ }
1172
+ )
1173
+ -- else if model.mods.shift then
1174
+ -- updateIfCards
1175
+ -- (\card c ->
1176
+ -- { c | highlight = not card.highlight }
1177
+ -- )
1178
+
1179
+ else if selecting then
1180
+ undoable { model | project = Project.update (Project.Order Project.MoveTop model.selected) model.project }
1181
+
1182
+ else
1183
+ n model
1184
+
1185
+ _ ->
1186
+ n model
1187
+
1188
+ DragMsg dragMsg ->
1189
+ Draggable.update dragConfig dragMsg model
1190
+
1191
+ StartDragWithPos id dragMsg event ->
1192
+ let
1193
+ maybeShape =
1194
+ Project.getShapeById model.project id
1195
+
1196
+ state =
1197
+ dragState maybeShape toLocal event
1198
+
1199
+ mode =
1200
+ if id == "background" then
1201
+ case state.button of
1202
+ LMB ->
1203
+ if model.mods.shift then
1204
+ CreateZone
1205
+
1206
+ else if model.mods.alt then
1207
+ Turn
1208
+
1209
+ else
1210
+ Select
1211
+
1212
+ MMB ->
1213
+ Pan
1214
+
1215
+ RMB ->
1216
+ CreateZone
1217
+
1218
+ else
1219
+ case state.button of
1220
+ LMB ->
1221
+ if model.mods.alt then
1222
+ Turn
1223
+
1224
+ else if model.mods.shift then
1225
+ Connect
1226
+
1227
+ else
1228
+ Move
1229
+
1230
+ MMB ->
1231
+ Pan
1232
+
1233
+ RMB ->
1234
+ Connect
1235
+
1236
+ selected =
1237
+ processSelected model id
1238
+ in
1239
+ { model
1240
+ | dragState =
1241
+ Just
1242
+ { state = { state | selecting = List.member id model.selected }
1243
+ , mode = mode
1244
+ }
1245
+ , project =
1246
+ if model.moveToTop then
1247
+ -- HACK this should be undoable
1248
+ Project.update (Project.Order MoveTop selected) model.project
1249
+
1250
+ else
1251
+ model.project
1252
+ , selected = selected
1253
+ , selectedCardModels = getSelectedCardModels model.project.cards selected
1254
+ }
1255
+ |> Draggable.update dragConfig dragMsg
1256
+
1257
+ DragBy delta ->
1258
+ case model.dragState of
1259
+ Nothing ->
1260
+ n model
1261
+
1262
+ Just ds ->
1263
+ let
1264
+ { state, mode } =
1265
+ ds
1266
+
1267
+ localDelta d =
1268
+ Vec.scale (model.project.viewport.size.x / rect.size.x) d
1269
+
1270
+ deltaMult =
1271
+ case mode of
1272
+ Turn ->
1273
+ 0.25
1274
+
1275
+ _ ->
1276
+ 1.0
1277
+
1278
+ nextState =
1279
+ { state | offset = Vec.add state.offset <| localDelta (Vec.scale deltaMult delta) }
1280
+
1281
+ pos =
1282
+ Vec.add nextState.start nextState.offset
1283
+
1284
+ m =
1285
+ { model | dragState = Just { ds | state = nextState } }
1286
+ in
1287
+ case mode of
1288
+ Pan ->
1289
+ update (Mod <| Project.Pan <| localDelta delta) m
1290
+
1291
+ Connect ->
1292
+ { m
1293
+ | selected = getHighestShapeByPos model.project pos |> Maybe.map (\( i, _ ) -> [ i ]) |> Maybe.withDefault []
1294
+ }
1295
+ |> n
1296
+
1297
+ _ ->
1298
+ n m
1299
+
1300
+ FinishSelection s ->
1301
+ selectInsideSelection model s
1302
+ |> n
1303
+
1304
+ EndDrag ->
1305
+ case model.dragState of
1306
+ Nothing ->
1307
+ n model
1308
+
1309
+ Just { state, mode } ->
1310
+ let
1311
+ m =
1312
+ { model | dragState = Nothing }
1313
+ in
1314
+ if Vec.length state.offset <= 5.0 then
1315
+ update (Click state.button state.shape state.start state.selecting) m
1316
+
1317
+ else
1318
+ case mode of
1319
+ CreateZone ->
1320
+ let
1321
+ s =
1322
+ { start = state.start, end = Vec.add state.start state.offset }
1323
+ in
1324
+ if Rect.area (normalizeRect s.start s.end) > 100 then
1325
+ let
1326
+ zonePreset =
1327
+ model.zonePreset
1328
+
1329
+ r =
1330
+ normalizeRect (snapPos model s.start) (snapPos model s.end)
1331
+
1332
+ newZone =
1333
+ { zonePreset
1334
+ | rect = r
1335
+ , id = "z" ++ String.fromInt model.id
1336
+ }
1337
+ in
1338
+ undoable
1339
+ { m
1340
+ | project = Project.update (Project.Spawn [ Project.Zone newZone ]) m.project
1341
+ , selected = [ newZone.id ]
1342
+ , selectedCardModels = ""
1343
+ , id = model.id + 1
1344
+ }
1345
+
1346
+ else
1347
+ n m
1348
+
1349
+ Select ->
1350
+ update (FinishSelection { start = state.start, end = Vec.add state.start state.offset }) m
1351
+
1352
+ Move ->
1353
+ { m
1354
+ | project =
1355
+ Project.update
1356
+ (Project.UpdateShapes model.selected
1357
+ (\s ->
1358
+ case s of
1359
+ Project.Arrow a ->
1360
+ Project.Arrow a
1361
+
1362
+ _ ->
1363
+ draggedTransform model s
1364
+ |> Project.transformShape s
1365
+ )
1366
+ )
1367
+ model.project
1368
+ }
1369
+ |> undoable
1370
+
1371
+ -- { m
1372
+ -- | project = Project.update (Project.Move state.offset model.selected) model.project
1373
+ -- }
1374
+ -- |> undoable
1375
+ Pan ->
1376
+ undoable model
1377
+
1378
+ Turn ->
1379
+ { m | project = Project.update (Project.Turn state.offset.x model.selected) model.project }
1380
+ |> undoable
1381
+
1382
+ Connect ->
1383
+ let
1384
+ pos =
1385
+ state.start |> Vec.add state.offset
1386
+ in
1387
+ toggleArrow m state.shape pos
1388
+ |> Maybe.map undoable
1389
+ |> Maybe.withDefault (n m)
1390
+
1391
+ -- ⣄⢀⡄⡠⠤⡀⡤⢄⠀⠠⡤⠀⡤⠤⠄⡄⠀⡄
1392
+ -- ⡇⠃⡇⡇⠀⡇⡇⠀⡇⠀⡇⠀⡗⠒⠀⠘⡜⠀
1393
+ -- ⠃⠀⠃⠑⠒⠁⠓⠊⠀⠐⠓⠀⠃⠀⠀⠀⠃⠀
1394
+ Mod message ->
1395
+ let
1396
+ project =
1397
+ Project.update message model.project
1398
+ in
1399
+ undoable { model | project = project }
1400
+
1401
+ ModRandom message ->
1402
+ let
1403
+ flags =
1404
+ model.flags
1405
+
1406
+ project =
1407
+ Project.updateWithSeed flags.seed message model.project
1408
+ in
1409
+ undoable { model | project = project, flags = { flags | seed = flags.seed + 1 } }
1410
+
1411
+ ApplySelector ->
1412
+ let
1413
+ nextModels =
1414
+ parseDeck model.selectedCardModels
1415
+ |> List.map2 (\id cardModel -> ( id, cardModel )) model.selected
1416
+
1417
+ project =
1418
+ Project.update
1419
+ (Project.UpdateShapes model.selected
1420
+ (\s ->
1421
+ case s of
1422
+ Project.Card c ->
1423
+ case
1424
+ nextModels
1425
+ |> List.filter (\( i, _ ) -> i == c.id)
1426
+ |> List.head
1427
+ of
1428
+ Just ( _, cardModel ) ->
1429
+ Project.Card { c | model = cardModel }
1430
+
1431
+ Nothing ->
1432
+ s
1433
+
1434
+ _ ->
1435
+ s
1436
+ )
1437
+ )
1438
+ model.project
1439
+ in
1440
+ undoable
1441
+ { model
1442
+ | project = project
1443
+ , selectedCardModels = getSelectedCardModels project.cards model.selected
1444
+ }
1445
+
1446
+ UpdateZoneString field string ->
1447
+ undoable
1448
+ { model
1449
+ | zonePreset = updateZoneString field string model.zonePreset
1450
+ , project =
1451
+ Project.update
1452
+ (Project.UpdateShapes model.selected
1453
+ (\s ->
1454
+ case s of
1455
+ Project.Zone z ->
1456
+ Project.Zone <|
1457
+ updateZoneString field string z
1458
+
1459
+ _ ->
1460
+ s
1461
+ )
1462
+ )
1463
+ model.project
1464
+ }
1465
+
1466
+ UpdateZoneRect id rc string ->
1467
+ undoable
1468
+ { model
1469
+ | project =
1470
+ Project.update
1471
+ (Project.UpdateZone id
1472
+ (\z ->
1473
+ { z
1474
+ | rect =
1475
+ modifyRect
1476
+ z.rect
1477
+ { pos = cardSize, size = cardSize }
1478
+ rc
1479
+ (String.toFloat string |> Maybe.withDefault 0.0)
1480
+ |> minSized cardSize
1481
+ }
1482
+ )
1483
+ )
1484
+ model.project
1485
+ }
1486
+
1487
+ SpawnStamp ->
1488
+ case model.stampInput of
1489
+ "" ->
1490
+ n model
1491
+
1492
+ s ->
1493
+ let
1494
+ stampPreset =
1495
+ model.stampPreset
1496
+
1497
+ text =
1498
+ stampPreset.text
1499
+
1500
+ stampId =
1501
+ "s" ++ String.fromInt model.id
1502
+
1503
+ stamp =
1504
+ { stampPreset
1505
+ | text = { text | label = s }
1506
+ , id = stampId
1507
+ , pos = center model.project.viewport
1508
+ }
1509
+ in
1510
+ undoable
1511
+ { model
1512
+ | project = Project.update (Project.Spawn [ Project.Stamp stamp ]) model.project
1513
+ , id = model.id + 1
1514
+ , stampInput = ""
1515
+ , selected = [ stampId ]
1516
+ }
1517
+
1518
+ Spawn ->
1519
+ case parseDeck model.command of
1520
+ [] ->
1521
+ n model
1522
+
1523
+ ls ->
1524
+ let
1525
+ additions =
1526
+ List.length ls
1527
+
1528
+ indices =
1529
+ List.range model.id (model.id + additions)
1530
+
1531
+ newCards =
1532
+ List.map2 (\cm id -> makeCard id cm (Vec2 0.0 0.0)) ls indices
1533
+ |> positionCards
1534
+ model.spreadType
1535
+ model.spreadValue
1536
+ (Vec.sub (center model.project.viewport) (Vec.half cardSize))
1537
+ |> List.reverse
1538
+ |> List.map (\c -> Project.Card c)
1539
+
1540
+ selected =
1541
+ indices |> List.map (String.fromInt >> (\i -> "c" ++ i))
1542
+ in
1543
+ undoable
1544
+ { model
1545
+ | project = Project.update (Project.Spawn newCards) model.project
1546
+ , command = ""
1547
+ , selected = selected
1548
+ , selectedCardModels = getSelectedCardModels model.project.cards selected
1549
+ , id = model.id + additions + 1
1550
+ }
1551
+
1552
+ Remove ->
1553
+ if List.isEmpty model.selected then
1554
+ n model
1555
+
1556
+ else
1557
+ undoable
1558
+ { model
1559
+ | project = Project.update (Project.Remove model.selected) model.project
1560
+ , selected = []
1561
+ , selectedCardModels = ""
1562
+ }
1563
+
1564
+ SelectAll ->
1565
+ let
1566
+ selected =
1567
+ List.map .id cards
1568
+ in
1569
+ n
1570
+ { model
1571
+ | selected = selected
1572
+ , selectedCardModels = getSelectedCardModels model.project.cards selected
1573
+ }
1574
+
1575
+ SelectArrow id ->
1576
+ n { model | selected = [ id ], selectedCardModels = "" }
1577
+
1578
+ SetArrowWidth s ->
1579
+ updateArrows
1580
+ (\a -> { a | width = Maybe.withDefault a.width <| String.toFloat s })
1581
+ model
1582
+ |> undoable
1583
+
1584
+ SetArrowHead s ->
1585
+ updateArrows
1586
+ (\a -> { a | head = headFromString s })
1587
+ model
1588
+ |> undoable
1589
+
1590
+ SetArrowColor c ->
1591
+ updateArrows
1592
+ (\a -> { a | fill = colorFromString c })
1593
+ model
1594
+ |> undoable
1595
+
1596
+ SetStampLabel s ->
1597
+ updateStamps
1598
+ (\a ->
1599
+ let
1600
+ text =
1601
+ a.text
1602
+ in
1603
+ { a | text = { text | label = s } }
1604
+ )
1605
+ model
1606
+ |> undoable
1607
+
1608
+ SetStampColor fill c ->
1609
+ if fill then
1610
+ updateStamps
1611
+ (\a -> { a | fill = colorFromString c })
1612
+ model
1613
+ |> undoable
1614
+
1615
+ else
1616
+ updateStamps
1617
+ (\a ->
1618
+ let
1619
+ t =
1620
+ a.text
1621
+ in
1622
+ { a | text = { t | color = colorFromString c } }
1623
+ )
1624
+ model
1625
+ |> undoable
1626
+
1627
+ SetStampSize fill c ->
1628
+ if fill then
1629
+ updateStamps
1630
+ (\a -> { a | radius = String.toFloat c |> Maybe.withDefault defaultStamp.radius })
1631
+ model
1632
+ |> undoable
1633
+
1634
+ else
1635
+ updateStamps
1636
+ (\a ->
1637
+ let
1638
+ t =
1639
+ a.text
1640
+ in
1641
+ { a | text = { t | size = String.toInt c |> Maybe.withDefault defaultStamp.text.size } }
1642
+ )
1643
+ model
1644
+ |> undoable
1645
+
1646
+ ToggleLaterMask layer ->
1647
+ let
1648
+ layers =
1649
+ model.unlockedLayers
1650
+ in
1651
+ undoable
1652
+ { model
1653
+ | unlockedLayers =
1654
+ case layer of
1655
+ Zones ->
1656
+ { layers | zones = not layers.zones }
1657
+
1658
+ Cards ->
1659
+ { layers | cards = not layers.cards }
1660
+
1661
+ Arrows ->
1662
+ { layers | arrows = not layers.arrows }
1663
+
1664
+ Stamps ->
1665
+ { layers | stamps = not layers.stamps }
1666
+ }
1667
+
1668
+ SpreadByType spreadType ->
1669
+ let
1670
+ next =
1671
+ { model
1672
+ | spreadType = spreadType
1673
+ , spreadValue = updateSpreadValue model.spreadType spreadType model.spreadValue
1674
+ }
1675
+ in
1676
+ case List.head model.selected of
1677
+ Just head ->
1678
+ update (SpreadCards head) next
1679
+
1680
+ Nothing ->
1681
+ n next
1682
+
1683
+ SpreadByValue string ->
1684
+ let
1685
+ next =
1686
+ { model | spreadValue = validateSpreadValue model.spreadType <| Maybe.withDefault 0 (String.toInt string) }
1687
+ in
1688
+ case List.head model.selected of
1689
+ Just head ->
1690
+ update (SpreadCards head) next
1691
+
1692
+ Nothing ->
1693
+ n next
1694
+
1695
+ -- FIX Editor - only move and snap selection leader
1696
+ SpreadCards _ ->
1697
+ let
1698
+ ( targets, _ ) =
1699
+ partitionSelected model
1700
+
1701
+ topLeft =
1702
+ targets
1703
+ |> (if model.spreadValue < 0 then
1704
+ List.foldl
1705
+ (\c a -> Vec.max c.rect.pos a)
1706
+ (Vec2 -10000.0 -10000.0)
1707
+
1708
+ else
1709
+ List.foldl
1710
+ (\c a -> Vec.min c.rect.pos a)
1711
+ (Vec2 10000.0 10000.0)
1712
+ )
1713
+
1714
+ lowest =
1715
+ targets |> List.map .z |> List.foldl min 0
1716
+
1717
+ spreadCards =
1718
+ targets
1719
+ |> List.reverse
1720
+ |> positionCards
1721
+ model.spreadType
1722
+ model.spreadValue
1723
+ topLeft
1724
+ |> List.reverse
1725
+ |> List.indexedMap (\i c -> { c | z = lowest + i })
1726
+
1727
+ -- |> List.reverse
1728
+ in
1729
+ undoable
1730
+ { model
1731
+ | project =
1732
+ model.project
1733
+ |> Project.update
1734
+ (Project.UpdateShapes (spreadCards |> List.map .id)
1735
+ (\s ->
1736
+ case s of
1737
+ Project.Card c ->
1738
+ case
1739
+ spreadCards |> List.filter (\sc -> sc.id == c.id) |> List.head
1740
+ of
1741
+ Just h ->
1742
+ Project.Card h
1743
+
1744
+ Nothing ->
1745
+ s
1746
+
1747
+ _ ->
1748
+ s
1749
+ )
1750
+ )
1751
+ }
1752
+
1753
+ -- ⡤⠤⠄⡤⢄⠀⠠⡤⠀⠤⡤⠄⡠⠤⡀⡤⠤⡀
1754
+ -- ⡗⠒⠀⡇⠀⡇⠀⡇⠀⠀⡇⠀⡇⠀⡇⡧⡤⠃
1755
+ -- ⠓⠒⠂⠓⠊⠀⠐⠓⠀⠀⠃⠀⠑⠒⠁⠃⠈⠂
1756
+ Toggle t ->
1757
+ case t of
1758
+ Setting s ->
1759
+ case s of
1760
+ EmbedSymbols ->
1761
+ save { model | settings = { settings | embedSymbols = not settings.embedSymbols } }
1762
+
1763
+ StripData ->
1764
+ save { model | settings = { settings | stripData = not settings.stripData } }
1765
+
1766
+ MapColors ->
1767
+ save { model | settings = { settings | mapColors = not settings.mapColors } }
1768
+
1769
+ MapFont ->
1770
+ save { model | settings = { settings | mapFont = not settings.mapFont } }
1771
+
1772
+ Boxed ->
1773
+ undoable { model | boxed = not model.boxed }
1774
+
1775
+ BringForward ->
1776
+ save { model | moveToTop = not model.moveToTop }
1777
+
1778
+ StackSelect ->
1779
+ save { model | stackSelect = not model.stackSelect }
1780
+
1781
+ ShowPanel p ->
1782
+ save { model | panel = p }
1783
+
1784
+ Undo ->
1785
+ case model.undos of
1786
+ head :: rest ->
1787
+ decodeSaveFileIntoClearedModel
1788
+ { model | undos = rest }
1789
+ (Json.Encode.encode 1 head)
1790
+ (\_ -> Cmd.none)
1791
+
1792
+ _ ->
1793
+ save model
1794
+
1795
+ UpdatePaletteColor target s ->
1796
+ let
1797
+ ( m, _ ) =
1798
+ undoable { model | palette = setPaletteColor model.palette target s }
1799
+ in
1800
+ ( m, outputString LoadPalette <| stringifyPalette m.palette )
1801
+
1802
+ TypeCommand s ->
1803
+ n { model | command = s }
1804
+
1805
+ TypeStamp s ->
1806
+ n { model | stampInput = s }
1807
+
1808
+ TypeSelector s ->
1809
+ n { model | selectedCardModels = s }
1810
+
1811
+ UpdateHeightPixels string ->
1812
+ let
1813
+ pixels =
1814
+ String.toInt string
1815
+ in
1816
+ case pixels of
1817
+ Just px ->
1818
+ n { model | settings = { settings | heightPixels = px } }
1819
+
1820
+ Nothing ->
1821
+ n model
1822
+
1823
+ -- UpdateSnap string ->
1824
+ -- case String.toInt string of
1825
+ -- Nothing ->
1826
+ -- n model
1827
+ -- Just i ->
1828
+ -- n { model | snap = Basics.clamp 0 8 i }
1829
+ -- UpdatePaletteString string ->
1830
+ -- n { model | paletteString = string }
1831
+ UpdatePalette palette ->
1832
+ let
1833
+ -- colors =
1834
+ -- String.split " " model.paletteString
1835
+ -- |> List.map String.trim
1836
+ -- |> List.filter (\s -> not <| String.isEmpty s)
1837
+ -- palette =
1838
+ -- if List.isEmpty colors then
1839
+ -- [ model.palette.black ]
1840
+ -- else
1841
+ -- colors
1842
+ ( m, _ ) =
1843
+ undoable { model | palette = palette }
1844
+ in
1845
+ ( m, outputString LoadPalette <| Json.Encode.encode 0 <| encodePalette palette )
1846
+
1847
+ TypeFont ft s ->
1848
+ let
1849
+ f =
1850
+ if s == "" then
1851
+ Nothing
1852
+
1853
+ else
1854
+ Just s
1855
+ in
1856
+ n
1857
+ { model
1858
+ | settings =
1859
+ case ft of
1860
+ PrimaryFont ->
1861
+ { settings
1862
+ | primaryFont = f
1863
+ }
1864
+
1865
+ SecondaryFont ->
1866
+ { settings
1867
+ | secondaryFont = f
1868
+ }
1869
+ }
1870
+
1871
+ UpdateFont ft ->
1872
+ ( model
1873
+ , outputString (LoadFont ft) <|
1874
+ Maybe.withDefault ""
1875
+ (if ft == PrimaryFont then
1876
+ model.settings.primaryFont
1877
+
1878
+ else
1879
+ model.settings.secondaryFont
1880
+ )
1881
+ )
1882
+
1883
+
1884
+
1885
+ -- OpenColorPicker c v ->
1886
+ -- n { model | pickedColor = Just c, colorPicker = ColorPicker.fromColor v }
1887
+ -- UpdateColorPicker cmsg ->
1888
+ -- case model.pickedColor of
1889
+ -- Nothing ->
1890
+ -- n model
1891
+ -- Just target ->
1892
+ -- let
1893
+ -- ( cp, color ) =
1894
+ -- ColorPicker.update cmsg model.colorPicker
1895
+ -- in
1896
+ -- let
1897
+ -- ( m, _ ) =
1898
+ -- undoable
1899
+ -- { model
1900
+ -- | colorPicker = cp
1901
+ -- , palette = setPaletteColor model.palette target color
1902
+ -- -- , pickedColor = Nothing
1903
+ -- }
1904
+ -- in
1905
+ -- UpdateCardField id field string ->
1906
+ -- let
1907
+ -- updateCard : Card -> Card
1908
+ -- updateCard c =
1909
+ -- if c.id == id then
1910
+ -- { c
1911
+ -- | model =
1912
+ -- case field of
1913
+ -- CardRank ->
1914
+ -- { rank =
1915
+ -- String.toInt string
1916
+ -- |> Maybe.withDefault (rankAsInt c.model.rank)
1917
+ -- |> rankFromInt
1918
+ -- , suit = c.model.suit
1919
+ -- }
1920
+ -- CardSuit ->
1921
+ -- { rank = c.model.rank
1922
+ -- , suit = Maybe.withDefault c.model.suit (suitFromString string)
1923
+ -- }
1924
+ -- }
1925
+ -- else
1926
+ -- c
1927
+ -- in
1928
+ -- mod { model | cards = model.cards |> List.map updateCard }
1929
+
1930
+
1931
+ snapFloat : Float -> Float -> Float
1932
+ snapFloat s f =
1933
+ round (f / s) * round s |> toFloat
1934
+
1935
+
1936
+ snapPos : Model -> Vec2 -> Vec2
1937
+ snapPos model pos =
1938
+ if not model.mods.ctrl then
1939
+ pos
1940
+
1941
+ else
1942
+ let
1943
+ s =
1944
+ toFloat 4
1945
+
1946
+ xs =
1947
+ cardSize.x / s
1948
+
1949
+ ys =
1950
+ cardSize.y / s
1951
+ in
1952
+ { x = snapFloat xs pos.x, y = snapFloat ys pos.y }
1953
+
1954
+
1955
+ snapAngle : Model -> Float -> Float
1956
+ snapAngle _ angle =
1957
+ -- if model.snap == 0 then
1958
+ -- angle
1959
+ -- else
1960
+ -- snapFloat (360.0 / (toFloat model.snap * 4)) angle
1961
+ angle
1962
+
1963
+
1964
+
1965
+ ---------------------------------------- ⡄⠀⡄⠠⡤⠀⡤⠤⠄⡄⠀⡄⡠⠤⡀
1966
+ ---------------------------------------- ⢱⢰⠁⠀⡇⠀⡗⠒⠀⡇⡄⡇⠑⠒⡄
1967
+ ---------------------------------------- ⠀⠃⠀⠐⠓⠀⠓⠒⠂⠋⠈⠃⠑⠒⠁
1968
+
1969
+
1970
+ rectCoordInput : (RectCoordinate -> String -> msg) -> Float -> RectCoordinate -> Html msg
1971
+ rectCoordInput message current rc =
1972
+ -- coordString : RectCoordinate -> String
1973
+ -- coordString rc =
1974
+ -- case rc of
1975
+ -- PosX ->
1976
+ -- "X"
1977
+ -- PosY ->
1978
+ -- "Y"
1979
+ -- SizeX ->
1980
+ -- "W"
1981
+ -- SizeY ->
1982
+ -- "H"
1983
+ H.span
1984
+ []
1985
+ -- [ H.h5 [] [ H.text <| coordString rc ]
1986
+ [ H.input
1987
+ [ HA.type_ "number"
1988
+ , HA.value <| String.fromFloat current
1989
+ , HA.class "narrow"
1990
+ , E.onInput (message rc)
1991
+ ]
1992
+ []
1993
+ ]
1994
+
1995
+
1996
+ rectInput : (RectCoordinate -> String -> msg) -> Rect -> Html msg
1997
+ rectInput fn rect =
1998
+ H.div [ HA.style "display" "flex" ]
1999
+ [ H.span []
2000
+ [ rectCoordInput fn (rect.pos.x / cardSize.x) PosX
2001
+ , rectCoordInput fn (rect.pos.y / cardSize.y) PosY
2002
+ ]
2003
+ , H.span []
2004
+ [ rectCoordInput fn (rect.size.x / cardSize.x) SizeX
2005
+ , rectCoordInput fn (rect.size.y / cardSize.y) SizeY
2006
+ ]
2007
+ ]
2008
+
2009
+
2010
+ viewportView : Rect -> Svg Msg
2011
+ viewportView viewport =
2012
+ let
2013
+ p =
2014
+ 4.0
2015
+
2016
+ pad =
2017
+ Vec2 p p
2018
+
2019
+ size =
2020
+ viewport.size |> Vec.add (Vec.scale 2.0 pad)
2021
+ in
2022
+ Svg.rect
2023
+ [ num Attr.x (viewport.pos.x - p)
2024
+ , num Attr.y (viewport.pos.y - p)
2025
+ , size.x |> num Attr.width
2026
+ , size.y |> num Attr.height
2027
+ , Attr.stroke "#fff"
2028
+ , Attr.fill "none"
2029
+ , Attr.strokeWidth (String.fromFloat <| p * 2.0)
2030
+ , Attr.pointerEvents "none"
2031
+ ]
2032
+ []
2033
+
2034
+
2035
+ viewportId : Model -> String
2036
+ viewportId m =
2037
+ "viewport" ++ String.fromInt m.flags.seed
2038
+
2039
+
2040
+ clipPath : Model -> Svg Msg
2041
+ clipPath m =
2042
+ let
2043
+ size =
2044
+ m.project.viewport.size
2045
+ in
2046
+ Svg.clipPath
2047
+ [ Attr.id <| viewportId m ]
2048
+ [ Svg.rect
2049
+ [ num Attr.x m.project.viewport.pos.x
2050
+ , num Attr.y m.project.viewport.pos.y
2051
+ , num Attr.width size.x
2052
+ , num Attr.height size.y
2053
+ ]
2054
+ []
2055
+ ]
2056
+
2057
+
2058
+ maybeHtml : Bool -> Html Msg -> Html Msg
2059
+ maybeHtml show element =
2060
+ if show then
2061
+ element
2062
+
2063
+ else
2064
+ H.text ""
2065
+
2066
+
2067
+
2068
+ -- isJust : Maybe a -> Bool
2069
+ -- isJust m =
2070
+ -- case m of
2071
+ -- Just _ ->
2072
+ -- True
2073
+ -- Nothing ->
2074
+ -- False
2075
+
2076
+
2077
+ enumOption : (String -> msg) -> (a -> String) -> (a -> Bool) -> List a -> Html msg
2078
+ enumOption message asString isCurrent options =
2079
+ H.select [ E.onInput message ]
2080
+ (List.map
2081
+ (\t ->
2082
+ let
2083
+ string =
2084
+ asString t
2085
+ in
2086
+ H.option
2087
+ [ HA.value string
2088
+ , isCurrent t |> HA.selected
2089
+ ]
2090
+ [ H.text string ]
2091
+ )
2092
+ options
2093
+ )
2094
+
2095
+
2096
+ enumButtons : (a -> msg) -> (a -> String) -> (a -> Bool) -> List ( a, String ) -> Html msg
2097
+ enumButtons message asString isCurrent options =
2098
+ H.span
2099
+ []
2100
+ (List.map
2101
+ (\( t, label ) ->
2102
+ H.button
2103
+ [ E.onClick (message t)
2104
+ , HA.class <|
2105
+ if isCurrent t then
2106
+ ""
2107
+
2108
+ else
2109
+ "inactive"
2110
+ , HA.class "enum-button"
2111
+ , HA.title (asString t)
2112
+ ]
2113
+ [ H.text label ]
2114
+ )
2115
+ options
2116
+ )
2117
+
2118
+
2119
+
2120
+ -- TODO UX - fix inputs not allowing backspace properly
2121
+ -- TODO UX - better number inputs?
2122
+
2123
+
2124
+ fieldInputRange : (String -> Msg) -> Maybe Int -> Maybe Int -> String -> Int -> Html Msg
2125
+ fieldInputRange msg mn mx n v =
2126
+ H.span
2127
+ []
2128
+ [ H.h5 [ HA.style "display" "inline-block" ] [ H.text n ]
2129
+ , H.input
2130
+ (List.concat
2131
+ [ mn |> Maybe.map (\i -> [ HA.min <| String.fromInt i ]) |> Maybe.withDefault []
2132
+ , mx |> Maybe.map (\i -> [ HA.max <| String.fromInt i ]) |> Maybe.withDefault []
2133
+ , [ E.onInput msg
2134
+ , HA.value <| String.fromInt v
2135
+ , HA.type_ "number"
2136
+ , HA.class "narrow"
2137
+ , style "width" "6em"
2138
+ ]
2139
+ ]
2140
+ )
2141
+ []
2142
+ ]
2143
+
2144
+
2145
+ fieldInput : (String -> Msg) -> String -> Int -> Html Msg
2146
+ fieldInput msg =
2147
+ fieldInputRange msg Nothing Nothing
2148
+
2149
+
2150
+ colorInput : Model -> (String -> Msg) -> String -> Color -> Html Msg
2151
+ colorInput model msg n v =
2152
+ H.span
2153
+ [ HA.class "inpector-column" ]
2154
+ [ H.text n
2155
+ , colorPickerWithTransparent model.palette msg v
2156
+ ]
2157
+
2158
+
2159
+ arrowInspector : Model -> Arrow -> Html Msg
2160
+ arrowInspector model a =
2161
+ H.div []
2162
+ [ H.text "" -- <| "arrow " ++ a.from ++ "->" ++ a.to
2163
+ , colorPicker False model.palette SetArrowColor a.fill
2164
+ , H.input
2165
+ [ HA.type_ "number"
2166
+ , E.onInput SetArrowWidth
2167
+ , HA.min "0"
2168
+ , HA.max "100"
2169
+ , HA.class "narrow"
2170
+ , HA.value <| String.fromFloat a.width
2171
+ ]
2172
+ []
2173
+ , enumOption
2174
+ SetArrowHead
2175
+ headString
2176
+ (\h -> h == a.head)
2177
+ [ Triangle, None, Cross ]
2178
+ ]
2179
+
2180
+
2181
+ stampInspector : Model -> Stamp -> Html Msg
2182
+ stampInspector model a =
2183
+ let
2184
+ symbolPicker ls =
2185
+ H.span
2186
+ [ HA.class "inspector-row"
2187
+ , HA.style "gap" "0.04rem"
2188
+ , HA.style "flex-wrap" "wrap"
2189
+ , HA.style "justify-content" "start"
2190
+ ]
2191
+ <|
2192
+ List.map
2193
+ (\s ->
2194
+ H.button
2195
+ [ E.onClick (SetStampLabel s)
2196
+ , HA.style "min-width" "1.5rem"
2197
+ ]
2198
+ [ H.text s ]
2199
+ )
2200
+ ls
2201
+ in
2202
+ H.div []
2203
+ [ H.h5 [] [ H.text "Stamp" ]
2204
+
2205
+ -- TODO UX - disallow same bg and fg for Stamps and Zones?
2206
+ , colorPicker True model.palette (SetStampColor True) a.fill
2207
+ , H.input
2208
+ [ HA.type_ "number"
2209
+ , E.onInput (SetStampSize True)
2210
+ , HA.min "0"
2211
+ , HA.max "100"
2212
+ , HA.class "narrow"
2213
+ , HA.value <| String.fromFloat a.radius
2214
+ ]
2215
+ []
2216
+ , colorPicker False model.palette (SetStampColor False) a.text.color
2217
+ , H.input
2218
+ [ HA.type_ "number"
2219
+ , E.onInput (SetStampSize False)
2220
+ , HA.min "0"
2221
+ , HA.max "400"
2222
+ , HA.class "narrow"
2223
+ , HA.value <| String.fromInt a.text.size
2224
+ ]
2225
+ []
2226
+ , H.input
2227
+ [ E.onInput SetStampLabel
2228
+ , HA.value a.text.label
2229
+ ]
2230
+ []
2231
+ , H.div [ HA.class "inpector-column" ]
2232
+ [ symbolPicker [ "A", "B", "C", "D", "E", "F", "G", "H", "I", "J" ]
2233
+ , symbolPicker [ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" ]
2234
+
2235
+ -- TODO add geometric symbols and stamps
2236
+ , symbolPicker <|
2237
+ List.concat
2238
+ [ [ "✓", "✔", "✔️", "✅", "☑️", "☑", "🗹" ]
2239
+ , [ "✖️", "✖", "❌", "𐄂", "❎", "🚫", "🔒", "⛔", "🛑" ]
2240
+ , [ "❗"
2241
+ , "❓"
2242
+ , "⚠️"
2243
+ , "💡"
2244
+ , "🧠"
2245
+ , "🎯"
2246
+ , "🧩"
2247
+ ]
2248
+ , [ "🏆", "🏅" ]
2249
+ , [ "⭐", "🌟", "✪", "★", "☆", "∗" ]
2250
+ ]
2251
+ ]
2252
+ , H.a [ HA.href "https://www.compart.com/en/unicode", HA.target "_blank" ] [ H.text "find more symbols to use..." ]
2253
+
2254
+ -- , enumOption
2255
+ -- SetArrowHead
2256
+ -- headString
2257
+ -- (\h -> h == a.head)
2258
+ -- [ Triangle, None, Cross ]
2259
+ ]
2260
+
2261
+
2262
+ zoneInspector : Model -> Bool -> Zone -> Html Msg
2263
+ zoneInspector model multi z =
2264
+ H.div
2265
+ [ HA.class "inspector-column"
2266
+ ]
2267
+ [ H.span [ HA.class "inspector-column" ]
2268
+ [ H.h5 [] [ H.text "Label" ]
2269
+ , H.textarea
2270
+ [ E.onInput (UpdateZoneString ZoneLabel)
2271
+ , HA.value z.text.label
2272
+ , HA.disabled multi
2273
+ , HA.classList [ ( "hidden", multi ) ]
2274
+ ]
2275
+ []
2276
+ ]
2277
+ , H.div [ HA.class "inspector-row" ]
2278
+ [ H.div []
2279
+ [ enumOption
2280
+ (UpdateZoneString ZoneAnchor)
2281
+ anchorString
2282
+ (\a -> a == z.text.anchor)
2283
+ [ Zone.Center, Zone.Top, Zone.Bottom, Zone.Left, Zone.Right ]
2284
+ ]
2285
+ , fieldInput
2286
+ (UpdateZoneString ZoneFontSize)
2287
+ "🗚"
2288
+ z.text.size
2289
+ ]
2290
+
2291
+ -- TODO toggle secondary font
2292
+ , H.span [ HA.class "inspector-column" ]
2293
+ [ H.h5 [] [ H.text "Font" ]
2294
+ , H.div
2295
+ [ HA.class "inpector-column" ]
2296
+ [ enumOption
2297
+ (UpdateZoneString ZoneFont)
2298
+ (\b ->
2299
+ if b then
2300
+ "Primary"
2301
+
2302
+ else
2303
+ "Secondary"
2304
+ )
2305
+ (\a -> a == z.text.primary)
2306
+ [ True, False ]
2307
+ , colorPicker False model.palette (UpdateZoneString ZoneText) z.text.color
2308
+ ]
2309
+ ]
2310
+ , H.span [ HA.class "inspector-column" ]
2311
+ [ H.h5 [] [ H.text "Fill ◼" ]
2312
+ , H.div []
2313
+ [ colorInput model (UpdateZoneString ZoneFill) "" z.fill
2314
+ ]
2315
+ ]
2316
+ , H.span [ HA.class "inspector-column" ]
2317
+ [ H.h5 [] [ H.text "Border ◻" ]
2318
+ , H.div []
2319
+ [ colorInput model (UpdateZoneString ZoneStroke) "" z.border.color
2320
+ , H.div [ HA.class "inspector-row" ]
2321
+ [ fieldInput (UpdateZoneString ZoneWidth) "Width" z.border.width
2322
+ , fieldInput (UpdateZoneString ZoneDash) "Dash ⬚" z.border.dash
2323
+ ]
2324
+ ]
2325
+ ]
2326
+
2327
+ -- , H.div [ HA.class "inspector-column" ]
2328
+ , H.div [ HA.classList [ ( "inspector-column", True ), ( "hidden", multi ) ] ]
2329
+ [ rectInput (UpdateZoneRect z.id) z.rect
2330
+ ]
2331
+ , H.span [ HA.class "inspector-column" ]
2332
+ [ H.h5 [] [ H.text "Grid" ]
2333
+ , H.div [ HA.class "inspector-row" ]
2334
+ [ fieldInputRange (UpdateZoneString ZoneColumns) (Just 0) (Just 32) "" z.grid.columns
2335
+ , fieldInputRange (UpdateZoneString ZoneRows) (Just 0) (Just 32) "" z.grid.rows
2336
+ ]
2337
+ ]
2338
+ ]
2339
+
2340
+
2341
+ cardInspector : Model -> Bool -> Card -> Html Msg
2342
+ cardInspector model multi _ =
2343
+ inspectorColumn
2344
+ []
2345
+ [ spreadView model
2346
+ |> maybeHtml multi
2347
+ ]
2348
+
2349
+
2350
+ selectedViews : Model -> Html Msg
2351
+ selectedViews model =
2352
+ let
2353
+ (Shapes zones cards arrows stamps) =
2354
+ model.project
2355
+ |> mergeAll
2356
+ |> List.filter (\( i, _ ) -> List.member i model.selected)
2357
+ |> splitAll
2358
+ in
2359
+ [ zones |> List.head |> Maybe.map (zoneInspector model (1 < List.length zones)) |> Maybe.withDefault (H.text "")
2360
+ , arrows |> List.head |> Maybe.map (arrowInspector model) |> Maybe.withDefault (H.text "")
2361
+ , cards |> List.head |> Maybe.map (cardInspector model (1 < List.length cards)) |> Maybe.withDefault (H.text "")
2362
+ , stamps |> List.head |> Maybe.map (stampInspector model) |> Maybe.withDefault (H.text "")
2363
+ ]
2364
+ |> H.div
2365
+ [ HA.class "inspector-column"
2366
+ ]
2367
+
2368
+
2369
+ fileViews : Model -> List (Html Msg)
2370
+ fileViews model =
2371
+ if List.member model.fileState [ New, NewModified ] then
2372
+ [ H.form
2373
+ [ E.onSubmit ChangeFilename ]
2374
+ [ H.input [ E.onInput TypeFilename, HA.value model.nameInput ] [] ]
2375
+ , H.button [ E.onClick SaveProject ] [ H.text "save" ]
2376
+ ]
2377
+
2378
+ else
2379
+ [ enumOption
2380
+ LoadProject
2381
+ (\s -> s)
2382
+ (\t -> t == model.name)
2383
+ model.files
2384
+ , case model.fileState of
2385
+ Modified ->
2386
+ H.button [ E.onClick SaveProject ] [ H.text "save" ]
2387
+
2388
+ _ ->
2389
+ H.button [ E.onClick NewProject ] [ H.text "new" ]
2390
+
2391
+ -- TODO add rename option
2392
+ , H.button [ E.onClick DuplicateProject ] [ H.text "clone" ]
2393
+ ]
2394
+
2395
+
2396
+ selectedRectsView : Model -> Svg msg
2397
+ selectedRectsView model =
2398
+ let
2399
+ selected =
2400
+ List.filterMap (getShapeById model.project) model.selected
2401
+
2402
+ selection =
2403
+ case model.dragState of
2404
+ Nothing ->
2405
+ []
2406
+
2407
+ Just { state, mode } ->
2408
+ if mode == Select || mode == CreateZone then
2409
+ let
2410
+ start =
2411
+ state.start
2412
+
2413
+ end =
2414
+ state.start |> Vec.add state.offset
2415
+ in
2416
+ [ ( normalizeRect start end, Vec.vec2 0.5 0.5, 0 ) ]
2417
+ -- [ ( normalizeRect (snapPos model start) (snapPos model end), Vec.vec2 0.5 0.5, 0 ) ]
2418
+
2419
+ else
2420
+ []
2421
+
2422
+ transforms =
2423
+ List.map
2424
+ (draggedTransform model)
2425
+ selected
2426
+ ++ selection
2427
+ in
2428
+ transforms
2429
+ |> List.map
2430
+ (\( rect, pivot, a ) ->
2431
+ let
2432
+ pos =
2433
+ -- rect.pos
2434
+ case model.dragState of
2435
+ Nothing ->
2436
+ rect.pos
2437
+
2438
+ Just { mode } ->
2439
+ if mode == Move then
2440
+ snapPos model rect.pos
2441
+
2442
+ else
2443
+ rect.pos
2444
+
2445
+ r =
2446
+ enlarge 15 { rect | pos = pos }
2447
+ in
2448
+ Svg.rect
2449
+ [ num Attr.width r.size.x
2450
+ , num Attr.height r.size.y
2451
+ , Attr.rx cornerRadius
2452
+ , Attr.transform <|
2453
+ pivotRotate pivot r.pos r.size a
2454
+ ++ translateXY r.pos
2455
+ , Attr.fill "none"
2456
+ , Attr.strokeDasharray ((String.fromFloat <| model.project.viewport.size.y / 100.0) ++ "px")
2457
+ , Attr.stroke "var(--fg)"
2458
+ , Attr.strokeWidth ((String.fromFloat <| model.project.viewport.size.y / 200.0) ++ "px")
2459
+
2460
+ -- , Attr.fill <| model.palette.yellow ++ "55"
2461
+ ]
2462
+ []
2463
+ )
2464
+ |> Svg.g
2465
+ [ style "pointer-events" "none"
2466
+ , Attr.class "selection"
2467
+ , removeOnExport
2468
+ ]
2469
+
2470
+
2471
+
2472
+ -- debugRect : Rect -> Svg Msg
2473
+ -- debugRect r =
2474
+ -- Svg.rect
2475
+ -- -- [ Attr.transform <| translate r.pos.x r.pos.y
2476
+ -- [ num Attr.x r.pos.x
2477
+ -- , num Attr.y r.pos.y
2478
+ -- , num Attr.width r.size.x
2479
+ -- , num Attr.height r.size.y
2480
+ -- , Attr.stroke "#f0f"
2481
+ -- , Attr.fill "transparent"
2482
+ -- , Attr.class "debugRect"
2483
+ -- , Attr.pointerEvents "none"
2484
+ -- ]
2485
+ -- []
2486
+ -- debugPoint : Vec2 -> Svg Msg
2487
+ -- debugPoint p =
2488
+ -- Svg.circle
2489
+ -- [ Attr.transform <| translate p.x p.y
2490
+ -- , num Attr.r 10.0
2491
+ -- , Attr.fill "#f0f"
2492
+ -- ]
2493
+ -- []
2494
+ -- debugCards : List Card -> Svg Msg
2495
+ -- debugCards cards =
2496
+ -- -- cardxs
2497
+ -- -- -- |> List.map (\c -> getShapeBounds c |> debugRect)
2498
+ -- -- -- |> List.map (\c -> rect2 c.position cardSize |> toPoints |> List.map (Vec.rotateAround (pivotPos c) c.rotation >> debugPoint))
2499
+ -- -- -- |> List.concat
2500
+ -- -- |>
2501
+ -- Svg.g
2502
+ -- []
2503
+ -- []
2504
+
2505
+
2506
+ svgView : Model -> Html Msg
2507
+ svgView model =
2508
+ Svg.svg
2509
+ [ HA.attribute "tabindex" "0"
2510
+ , Attr.id "view"
2511
+ , onWheel (Mod << Project.Zoom)
2512
+ , preventContextMenu
2513
+ , dragEvent "background"
2514
+ , onMouseUp EndDrag
2515
+
2516
+ -- , Draggable.customMouseTrigger "background" (positionDecoder "offset") CustomMouseDown
2517
+ -- , onMouseUp StopDragging
2518
+ -- , E.onDoubleClick SelectAll
2519
+ -- , HA.attribute "data-view" <| Debug.toString model.project.viewport
2520
+ -- , HA.attribute "data-mouse" <| Debug.toString model.mouse
2521
+ , HA.attribute "data-project" <| Json.Encode.encode 0 <| serializeModel model
2522
+ , Attr.viewBox <| rectString model.project.viewport
2523
+
2524
+ -- , if model.boxed then
2525
+ -- else
2526
+ -- Attr.visibility "visible"
2527
+ , Attr.overflow "hidden"
2528
+ ]
2529
+ (List.append
2530
+ includes
2531
+ ([ ( False, H.text "" ) -- ( True, cardMasks model )
2532
+ , ( True, arrowDefs )
2533
+ , ( True, background model )
2534
+ , ( True, clipPath model )
2535
+ , ( True, zonesView model )
2536
+ , maybeIncompleteZone model
2537
+
2538
+ -- , ( False, debugCards model.cards )
2539
+ , ( True, cardsView model )
2540
+ , ( True, arrowsView model )
2541
+ , ( True, stampsView model )
2542
+ , ( True, incompleteArrow model )
2543
+
2544
+ -- , ( False
2545
+ -- , case model.dragState of
2546
+ -- Just {state, _} ->
2547
+ -- Svg.circle
2548
+ -- [ Attr.transform <| translateXY
2549
+ -- , Attr.r "10"
2550
+ -- , Attr.fill "#f00"
2551
+ -- , Attr.pointerEvents "none"
2552
+ -- ]
2553
+ -- [ Svg.text id
2554
+ -- ]
2555
+ -- _ ->
2556
+ -- Svg.g [] []
2557
+ -- )
2558
+ , ( not model.boxed, viewportView model.project.viewport )
2559
+ , ( True, selectedRectsView model )
2560
+ ]
2561
+ |> List.filter Tuple.first
2562
+ |> List.map Tuple.second
2563
+ )
2564
+ )
2565
+
2566
+
2567
+ maybeIncompleteZone : Model -> ( Bool, Html Msg )
2568
+ maybeIncompleteZone model =
2569
+ model.dragState
2570
+ |> Maybe.andThen
2571
+ (\{ state, mode } ->
2572
+ if mode == CreateZone then
2573
+ let
2574
+ s =
2575
+ { start = state.start, end = Vec.add state.start state.offset }
2576
+
2577
+ zonePreset =
2578
+ model.zonePreset
2579
+
2580
+ r =
2581
+ normalizeRect (snapPos model s.start) (snapPos model s.end)
2582
+
2583
+ newZone =
2584
+ { zonePreset
2585
+ | rect = r
2586
+ , id = "z" ++ String.fromInt model.id
2587
+ }
2588
+ in
2589
+ Just
2590
+ ( True
2591
+ , Tuple.second <| zoneView model False newZone
2592
+ )
2593
+
2594
+ else
2595
+ Nothing
2596
+ )
2597
+ |> Maybe.withDefault ( False, H.text "" )
2598
+
2599
+
2600
+ spreadView : Model -> Html Msg
2601
+ spreadView model =
2602
+ H.span
2603
+ [ HA.class "inspector-column" ]
2604
+ [ -- H.button
2605
+ -- [ E.onClick (SpreadCards "") ]
2606
+ -- [ H.text "spread" ],
2607
+ enumButtons
2608
+ SpreadByType
2609
+ spreadAsString
2610
+ (\t -> t == model.spreadType)
2611
+ [ ( Horizontal, "🠚" ), ( Vertical, "🠛" ), ( Diagonal, "➘" ), ( Rows, "⇉" ), ( Columns, "⇊" ), ( Stack, "🂠" ), ( Fan, "⌓" ) ]
2612
+ , H.input
2613
+ [ E.onInput SpreadByValue
2614
+ , HA.type_ "number"
2615
+ , HA.class "narrow"
2616
+ , HA.value (String.fromInt model.spreadValue)
2617
+ ]
2618
+ []
2619
+ , H.button
2620
+ [ E.onClick (ModRandom (Project.Shuffle model.selected)) ]
2621
+ [ H.text "shuffle" ]
2622
+ ]
2623
+
2624
+
2625
+ paletteSettings : Model -> Html Msg
2626
+ paletteSettings model =
2627
+ let
2628
+ themes =
2629
+ Theme.themes
2630
+ |> List.map
2631
+ (\( name, p ) ->
2632
+ H.button
2633
+ [ E.onClick (UpdatePalette p)
2634
+ ]
2635
+ [ H.text name ]
2636
+ )
2637
+
2638
+ colors =
2639
+ Palette.paletteAsList model.palette
2640
+ |> List.map
2641
+ (\( color, value ) ->
2642
+ let
2643
+ name =
2644
+ Palette.colorAsString color
2645
+ in
2646
+ H.span
2647
+ [ HA.style "white-space" "nowrap" ]
2648
+ [ H.text <| String.toUpper name
2649
+ , H.input
2650
+ [ HA.type_ "color"
2651
+ , HA.value value
2652
+ , E.onInput <| UpdatePaletteColor color
2653
+ ]
2654
+ []
2655
+ ]
2656
+ )
2657
+ in
2658
+ H.span
2659
+ []
2660
+ [ H.div [] themes
2661
+ , H.div [] [ H.span [] colors ]
2662
+ ]
2663
+
2664
+
2665
+ panelView : Model -> Html Msg
2666
+ panelView m =
2667
+ case m.panel of
2668
+ Just p ->
2669
+ H.div [ Attr.id "panel" ]
2670
+ [ H.div [ Attr.id "panel-header" ]
2671
+ [ H.h3
2672
+ []
2673
+ [ H.text
2674
+ (case p of
2675
+ Help ->
2676
+ "Help Desck"
2677
+
2678
+ ExportSettings ->
2679
+ "Export Project"
2680
+
2681
+ Settings ->
2682
+ "Settings"
2683
+ )
2684
+ ]
2685
+ , H.button
2686
+ [ Attr.id "panel-close"
2687
+ , E.onClick <| Toggle <| ShowPanel Nothing
2688
+ ]
2689
+ [ H.text "X" ]
2690
+ ]
2691
+ , H.div [ Attr.id "panel-body" ] <|
2692
+ case p of
2693
+ Help ->
2694
+ Markdown.toHtml Nothing
2695
+ m.flags.help
2696
+ ++ [ H.div []
2697
+ [ H.text "If you enjoy this tool, consider "
2698
+ , H.a
2699
+ [ HA.href "https://ko-fi.com/unlessgames" ]
2700
+ [ H.text "donating♥" ]
2701
+ ]
2702
+ , H.div []
2703
+ [ H.text "Check out the source on "
2704
+ , H.a
2705
+ [ HA.href "https://gitlab.com/unlessgames/desck" ]
2706
+ [ H.text "gitlab" ]
2707
+ ]
2708
+ ]
2709
+
2710
+ Settings ->
2711
+ [ H.h3 [] [ H.text "Fonts" ]
2712
+ , H.div [ Attr.class "panel-row" ]
2713
+ [ H.form
2714
+ [ E.onSubmit (UpdateFont PrimaryFont) ]
2715
+ [ H.input
2716
+ [ E.onInput (TypeFont PrimaryFont)
2717
+ , HA.value <| Maybe.withDefault "" m.settings.primaryFont
2718
+ , HA.placeholder "primary font"
2719
+ , HA.title "type the name of a custom font from Google Fonts (for example 'Roboto Condensed')"
2720
+ ]
2721
+ []
2722
+ ]
2723
+ , H.form
2724
+ [ E.onSubmit (UpdateFont SecondaryFont) ]
2725
+ [ H.input
2726
+ [ E.onInput (TypeFont SecondaryFont)
2727
+ , HA.value <| Maybe.withDefault "" m.settings.secondaryFont
2728
+ , HA.placeholder "secondary font"
2729
+ , HA.title "type the name of a custom font from Google Fonts (for example 'Roboto Condensed')"
2730
+ ]
2731
+ []
2732
+ ]
2733
+ ]
2734
+ , H.h3 [] [ H.text "Palette" ]
2735
+ , paletteSettings m
2736
+
2737
+ -- TODO Editor - add automatic humanize on spawn and pick/drop
2738
+ -- TODO Editor - crop padding setting
2739
+ -- TODO Editor - contain or intersect selection mode
2740
+ , H.span []
2741
+ [ H.h3 [] [ H.text "Background" ]
2742
+ , colorPickerWithTransparent m.palette (Mod << Project.SetBackground) m.project.background
2743
+ ]
2744
+ ]
2745
+
2746
+ ExportSettings ->
2747
+ -- TODO Editor - toggle background include
2748
+ -- TODO Editor - reset defaults
2749
+ let
2750
+ -- TODO GUI - better switches
2751
+ settingFlag sf st on off desc =
2752
+ H.button
2753
+ [ E.onClick <| Toggle (Setting sf)
2754
+ , Attr.title desc
2755
+ ]
2756
+ [ H.text <|
2757
+ if st then
2758
+ on
2759
+
2760
+ else
2761
+ off
2762
+ ]
2763
+ in
2764
+ [ H.h3 [] [ H.text "Download" ]
2765
+ , H.div [ Attr.class "panel-row" ]
2766
+ [ H.button [ E.onClick <| Export SVG ] [ H.text "⤓ SVG" ]
2767
+ , H.button [ E.onClick <| Export PNG ] [ H.text "⤓ PNG" ]
2768
+ , H.button [ E.onClick <| Export JSON ] [ H.text "⤓ JSON" ]
2769
+ ]
2770
+ , H.h3 [] [ H.text "SVG Settings" ]
2771
+ , H.div [ Attr.class "panel-row" ]
2772
+ [ settingFlag
2773
+ EmbedSymbols
2774
+ m.settings.embedSymbols
2775
+ "embed SVG symbols"
2776
+ "strip SVG symbols"
2777
+ "choose to embed or strip the symbols (like face cards, pips and digits) for the SVG output, when stripped multiple SVG can be embed on a page with a single symbol library at the top to save on bandwidth (you'll have to set up inlining all of the SVGs and the symbol library)"
2778
+ , H.button [ E.onClick <| Export Symbols ] [ H.text "⤓ Export SVG symbol library" ]
2779
+ |> maybeHtml (not m.settings.embedSymbols)
2780
+ ]
2781
+ , settingFlag
2782
+ StripData
2783
+ m.settings.stripData
2784
+ "exclude project data"
2785
+ "include project data"
2786
+ "You may choose to strip the JSON formatted data that is inserted into the top of the SVG for desck to be able load the .svg file, if used in automated build, it is good to get rid of this to save some bandwidth"
2787
+ , settingFlag
2788
+ MapColors
2789
+ m.settings.mapColors
2790
+ "colors as values"
2791
+ "colors as references"
2792
+ "map the actual color values you selected in Desck to the SVG, when disabled, colors are kept as CSS variables like 'var(--red)' so that you can dynamically remap them later by swapping variable definitions on your page"
2793
+ , settingFlag
2794
+ MapFont
2795
+ m.settings.mapFont
2796
+ "fonts as values"
2797
+ "fonts as references"
2798
+ "map the actual font values you selected in Desck to the styles in the SVG, when disabled, fonts are kept as CSS variables like 'var(--desck-primary-font)' and 'var(--desck-secondary-font)' so that you can dynamically remap them later by including variable definitions on your page"
2799
+ , H.textarea
2800
+ [ E.onInput (Mod << Project.Describe)
2801
+ , HA.value m.project.description
2802
+ , HA.placeholder "🖹 describe the image"
2803
+ , HA.title "write a description that will be saved as the 'title' attribute for the SVG"
2804
+ ]
2805
+ []
2806
+ , H.h3 [] [ H.text "PNG Settings" ]
2807
+ , H.div [ Attr.class "panel-row" ]
2808
+ [ H.button [] [ H.text "height of cards" ]
2809
+ , H.input
2810
+ [ HA.type_ "number"
2811
+ , HA.min "10"
2812
+ , HA.max "1000"
2813
+ , HA.value <| String.fromInt m.settings.heightPixels
2814
+ , E.onInput UpdateHeightPixels
2815
+ , HA.class "narrow"
2816
+ , HA.id "heightPixels"
2817
+ , HA.title "set the height of cards in pixels in the exported PNG image"
2818
+ ]
2819
+ []
2820
+ ]
2821
+ ]
2822
+ ]
2823
+
2824
+ Nothing ->
2825
+ H.text ""
2826
+
2827
+
2828
+ panelToggle : Panel -> String -> Html Msg
2829
+ panelToggle msg s =
2830
+ H.button
2831
+ [ E.onClick <| Toggle <| ShowPanel <| Just msg
2832
+ , Attr.class "panel-toggle"
2833
+ ]
2834
+ [ H.text s
2835
+ ]
2836
+
2837
+
2838
+ headerView : Model -> Html Msg
2839
+ headerView model =
2840
+ List.concat
2841
+ [ fileViews model
2842
+ , [ H.button [ E.onClick UploadFile ] [ H.text "load" ]
2843
+ , H.button [ E.onClick <| Toggle <| Boxed ] [ H.text "▭" ]
2844
+ , rectInput (\c i -> Mod <| Project.UpdateViewport c i) model.project.viewport
2845
+ , H.button [ E.onClick (Mod <| Project.Crop model.selected) ] [ H.text "crop" ]
2846
+ , H.button [ E.onClick <| Toggle BringForward ]
2847
+ [ H.text <|
2848
+ if model.moveToTop then
2849
+ "move to top"
2850
+
2851
+ else
2852
+ "keep order"
2853
+ ]
2854
+ , H.button [ E.onClick <| Toggle StackSelect ]
2855
+ [ H.text <|
2856
+ if model.stackSelect then
2857
+ "stack select"
2858
+
2859
+ else
2860
+ "single select"
2861
+ ]
2862
+ ]
2863
+ ]
2864
+ |> H.div
2865
+ [ Attr.id "header"
2866
+ ]
2867
+
2868
+
2869
+ inspectorColumn : List (H.Attribute msg) -> List (Html msg) -> Html msg
2870
+ inspectorColumn attrs ls =
2871
+ H.div
2872
+ (HA.class "inspector-column"
2873
+ :: attrs
2874
+ )
2875
+ ls
2876
+
2877
+
2878
+ inspectorRow : List (Html msg) -> Html msg
2879
+ inspectorRow ls =
2880
+ H.div
2881
+ [ HA.class "inspector-row"
2882
+ ]
2883
+ ls
2884
+
2885
+
2886
+ inspectorView : Model -> Html Msg
2887
+ inspectorView model =
2888
+ let
2889
+ layerToggle layer icon ls field =
2890
+ inspectorColumn
2891
+ [ HA.classList
2892
+ [ ( "inactive", not (field model.unlockedLayers) )
2893
+ ]
2894
+ ]
2895
+ [ H.button
2896
+ [ E.onClick (ToggleLaterMask layer)
2897
+ , HA.title <| layerString layer
2898
+ ]
2899
+ [ H.text icon ]
2900
+ , inspectorRow [ H.text <| String.fromInt <| List.length ls ]
2901
+ ]
2902
+ in
2903
+ [ H.text ""
2904
+ , H.div []
2905
+ [ inspectorRow
2906
+ [ layerToggle Zones "⌧" model.project.zones .zones
2907
+ , layerToggle Cards "🂠" model.project.cards .cards
2908
+ , layerToggle Arrows "↣" model.project.arrows .arrows
2909
+ , layerToggle Stamps "✪" model.project.stamps .stamps
2910
+ ]
2911
+ , inspectorRow
2912
+ [ H.button
2913
+ [ E.onClick <|
2914
+ ModRandom <|
2915
+ Project.Humanize model.selected
2916
+ ]
2917
+ [ H.text <|
2918
+ "humanize"
2919
+ ++ (if List.isEmpty model.selected then
2920
+ " all"
2921
+
2922
+ else
2923
+ " selected"
2924
+ )
2925
+ ]
2926
+ ]
2927
+
2928
+ -- enumButtons
2929
+ -- ToggleLaterMask
2930
+ -- layerString
2931
+ -- (\t -> t == )
2932
+ --, ( Colors, "◍" ) ]
2933
+ ]
2934
+ , H.div
2935
+ []
2936
+ [-- , H.span
2937
+ -- []
2938
+ -- [ H.button [ E.onClick (Mod <| Project.Order Project.MoveUp model.selected) ] [ H.text "⯅" ]
2939
+ -- , H.button [ E.onClick (Mod <| Project.Order Project.MoveDown model.selected) ] [ H.text "⯆" ]
2940
+ -- -- , H.input [ E.onInput RotateSelected, HA.type_ "number", HA.class "narrow" ] []
2941
+ -- ]
2942
+ -- |> maybeHtml (1 == List.length model.selected && model.tool == Cards)
2943
+ ]
2944
+ |> maybeHtml (0 < List.length model.selected)
2945
+
2946
+ -- , H.span []
2947
+ -- [ H.text "snap"
2948
+ -- , H.input
2949
+ -- [ HA.type_ "number"
2950
+ -- , HA.value <| String.fromInt model.snap
2951
+ -- , HA.class "narrow"
2952
+ -- , HA.min "0"
2953
+ -- , HA.max "8"
2954
+ -- , E.onInput UpdateSnap
2955
+ -- ]
2956
+ -- []
2957
+ -- ]
2958
+ -- , H.text (status model)
2959
+ , selectedViews
2960
+ model
2961
+ ]
2962
+ |> H.div
2963
+ [ HA.id "inspector"
2964
+ ]
2965
+
2966
+
2967
+ footerView : Model -> Html Msg
2968
+ footerView model =
2969
+ H.div
2970
+ [ HA.id "footer" ]
2971
+ [ H.div []
2972
+ [ H.button
2973
+ [ E.onClick Remove
2974
+ , HA.style "color" "var(--red)"
2975
+ , HA.classList
2976
+ [ ( "inactive", List.isEmpty model.selected )
2977
+ ]
2978
+ ]
2979
+ [ H.text "remove" ]
2980
+ , H.button
2981
+ [ E.onClick Undo
2982
+ , HA.classList
2983
+ [ ( "inactive"
2984
+ , 0 == List.length model.undos
2985
+ )
2986
+ ]
2987
+ , HA.title "Undo"
2988
+ ]
2989
+ [ H.text "↺"
2990
+ ]
2991
+
2992
+ -- [ H.text <| "undo [" ++ (String.fromInt <| List.length model.undos) ++ "]"
2993
+ ]
2994
+ , H.form
2995
+ [ E.onSubmit Spawn ]
2996
+ [ H.input
2997
+ [ E.onInput TypeCommand
2998
+ , HA.value model.command
2999
+ , HA.title "spawn cards with codes like 'ac 9d k'"
3000
+ , HA.placeholder "spawn cards like '6c ad qh'"
3001
+ ]
3002
+ []
3003
+ ]
3004
+ , H.form
3005
+ [ E.onSubmit SpawnStamp ]
3006
+ [ H.input
3007
+ [ E.onInput TypeStamp
3008
+ , HA.value model.stampInput
3009
+ , HA.title "spawn a stamps with characters like '◬ '"
3010
+ , HA.placeholder "spawn stamps with characters like '◬ '"
3011
+ ]
3012
+ []
3013
+ ]
3014
+ , H.form
3015
+ [ E.onSubmit ApplySelector ]
3016
+ [ H.input
3017
+ [ E.onInput TypeSelector
3018
+ , HA.value model.selectedCardModels
3019
+ , HA.title "modify cards by changing their codes"
3020
+ , HA.placeholder "select some cards to edit..."
3021
+ ]
3022
+ []
3023
+ ]
3024
+ , H.div []
3025
+ [ panelToggle Help "? Help"
3026
+ , panelToggle Settings "◍ Settings"
3027
+ , panelToggle ExportSettings "⤓ Download"
3028
+ , H.a [ HA.href "https://ko-fi.com/unlessgames" ] [ H.button [ HA.id "support-button" ] [ H.text "♥" ] ]
3029
+ ]
3030
+ ]
3031
+
3032
+
3033
+ view : Model -> Html Msg
3034
+ view model =
3035
+ H.div
3036
+ [ Attr.id "main"
3037
+ ]
3038
+ [ headerView model
3039
+ , inspectorView model
3040
+ , svgView model
3041
+ , panelView model
3042
+ , footerView model
3043
+ ]
3044
+
3045
+
3046
+ viewportAttr : Model -> Svg.Attribute Msg
3047
+ viewportAttr m =
3048
+ if m.boxed then
3049
+ Attr.clipPath (url <| viewportId m)
3050
+
3051
+ else
3052
+ Attr.visibility "visible"
3053
+
3054
+
3055
+ cull : Project -> (a -> Shape) -> a -> Bool
3056
+ cull p toShape s =
3057
+ s
3058
+ |> toShape
3059
+ |> shapeRect p
3060
+ |> overlaps (enlarge cardSize.x p.viewport)
3061
+
3062
+
3063
+
3064
+ -- TODO Card - different backs, choose red or blue for each card?
3065
+
3066
+
3067
+ cardsView : Model -> Svg Msg
3068
+ cardsView model =
3069
+ model.project.cards
3070
+ |> List.filter
3071
+ (cull model.project Project.Card)
3072
+ |> List.sortBy .z
3073
+ |> List.map (cardView model)
3074
+ |> Svg.Keyed.node "g" [ viewportAttr model ]
3075
+
3076
+
3077
+ arrowDefs : Svg msg
3078
+ arrowDefs =
3079
+ let
3080
+ w =
3081
+ 3.0
3082
+
3083
+ h =
3084
+ 1.5
3085
+
3086
+ w2 =
3087
+ w / 2.0
3088
+ in
3089
+ Svg.defs []
3090
+ [ Svg.marker
3091
+ [ Attr.id "arrow"
3092
+ , num Attr.markerWidth h
3093
+ , num Attr.markerHeight w
3094
+ , num Attr.refX (h * 0.5)
3095
+ , num Attr.refY w2
3096
+ , Attr.orient "auto"
3097
+ , Attr.markerUnits "strokeWidth"
3098
+ , Attr.fill "context-stroke"
3099
+ ]
3100
+ [ Svg.polygon
3101
+ [ Attr.fill "context-stroke"
3102
+ , Attr.points <| "0,0 0," ++ String.fromFloat w ++ " " ++ String.fromFloat h ++ "," ++ String.fromFloat w2
3103
+ ]
3104
+ []
3105
+ ]
3106
+
3107
+ -- , Svg.filter
3108
+ -- [ Attr.id "shadow" ]
3109
+ -- [ Svg.node "feDropShadow"
3110
+ -- [ Attr.dx "4px"
3111
+ -- , Attr.dy "4px"
3112
+ -- , Attr.floodColor "black"
3113
+ -- , Attr.stdDeviation "4"
3114
+ -- ]
3115
+ -- []
3116
+ -- ]
3117
+ ]
3118
+
3119
+
3120
+ dragEvent : Id -> Svg.Attribute Msg
3121
+ dragEvent id =
3122
+ Draggable.customMouseTrigger id (positionDecoder "offset") (StartDragWithPos id)
3123
+
3124
+
3125
+
3126
+ -- TODO customize arrow cap or distance?
3127
+ -- TODO respond to pivot and angle
3128
+
3129
+
3130
+ arcPath : Float -> ( Vec2, Vec2 ) -> String
3131
+ arcPath f ( a, b ) =
3132
+ let
3133
+ p v =
3134
+ String.fromFloat v.x ++ " " ++ String.fromFloat v.y
3135
+
3136
+ m =
3137
+ Vec.sub b a
3138
+ |> Vec.scale 0.5
3139
+
3140
+ c =
3141
+ Vec.vec2 0.0 f
3142
+ |> Vec.rotate pi
3143
+ in
3144
+ "M " ++ p a ++ " Q " ++ p c ++ " " ++ p b
3145
+
3146
+
3147
+ arrowView : Model -> ( Vec2, Vec2 ) -> Arrow -> ( String, Svg Msg )
3148
+ arrowView model ( from, to ) a =
3149
+ let
3150
+ ( rect, pivot, angle ) =
3151
+ arrowTransform a ( from, to )
3152
+
3153
+ c =
3154
+ center rect
3155
+
3156
+ dir =
3157
+ Vec.sub from to
3158
+
3159
+ l =
3160
+ Vec.length dir
3161
+ * 0.4
3162
+ in
3163
+ ( a.id
3164
+ , Svg.g
3165
+ [ Attr.transform <|
3166
+ pivotRotate (Vec.vec2 0.5 0.5) rect.pos rect.size angle
3167
+ ++ translateXY c
3168
+ ]
3169
+ [ Svg.line
3170
+ ([ num Attr.x1 <| l
3171
+ , num Attr.y1 0.0
3172
+ , num Attr.x2 <| -l
3173
+ , num Attr.y2 0.0
3174
+ , num Attr.strokeWidth a.width
3175
+ , Attr.fill <| getColor model a.fill
3176
+ , Attr.stroke <| getColor model a.fill
3177
+ , Attr.strokeLinecap "butt"
3178
+ , preventContextMenu
3179
+ , dragEvent a.id
3180
+
3181
+ -- , num Attr.opacity 0.0
3182
+ -- , E.onDoubleClick (Mod (Project.Remove model.selected))
3183
+ ]
3184
+ ++ (case a.head of
3185
+ Triangle ->
3186
+ [ Attr.markerEnd (url "arrow") ]
3187
+
3188
+ -- TODO Arrow - cross head!
3189
+ _ ->
3190
+ []
3191
+ )
3192
+ )
3193
+ []
3194
+
3195
+ -- TODO Arrow - curved path
3196
+ -- , Svg.path
3197
+ -- [ Attr.d <| arcPath 100.0 ( Vec.vec2 l 0.0, Vec.vec2 -l 0.0 )
3198
+ -- , num Attr.strokeWidth a.width
3199
+ -- , Attr.fill <| "transparent"
3200
+ -- , Attr.stroke <| getColor model a.fill
3201
+ -- , Attr.strokeLinecap "butt"
3202
+ -- , Attr.markerEnd (url "arrow")
3203
+ -- ]
3204
+ -- []
3205
+ , Svg.text_
3206
+ [ HA.style "font-size" "32px"
3207
+ , Attr.fill "magenta"
3208
+ ]
3209
+ [ Svg.text <| a.id ]
3210
+ |> maybeHtml model.debug
3211
+ ]
3212
+ )
3213
+
3214
+
3215
+ arrowsView : Model -> Svg Msg
3216
+ arrowsView model =
3217
+ model.project.arrows
3218
+ |> List.filter
3219
+ (cull model.project Project.Arrow)
3220
+ |> List.map
3221
+ (\a ->
3222
+ let
3223
+ points =
3224
+ case ( getShapeById model.project a.from, getShapeById model.project a.to ) of
3225
+ ( Just from, Just to ) ->
3226
+ arrowPoints ( draggedTransform model from, draggedTransform model to )
3227
+
3228
+ _ ->
3229
+ ( Vec.zero, Vec.zero )
3230
+ in
3231
+ arrowView model points a
3232
+ )
3233
+ |> Svg.Keyed.node "g" [ viewportAttr model, Attr.id "arrows" ]
3234
+
3235
+
3236
+ stampView : Model -> Stamp -> ( String, Svg Msg )
3237
+ stampView m s =
3238
+ let
3239
+ ( rect, _, _ ) =
3240
+ draggedTransform m (Project.Stamp s)
3241
+ in
3242
+ ( s.id
3243
+ , Svg.g
3244
+ [ Attr.transform <| translate (rect.pos.x + s.radius) (rect.pos.y + s.radius)
3245
+ , preventContextMenu
3246
+ , dragEvent s.id
3247
+ , onMouseUp EndDrag
3248
+ ]
3249
+ [ Svg.circle
3250
+ [ Attr.r (String.fromFloat s.radius ++ "px")
3251
+ , Attr.fill <| getColor m s.fill
3252
+ ]
3253
+ []
3254
+ , Svg.text_
3255
+ [ Attr.fill <| getColor m s.text.color
3256
+ , Attr.fontSize <| String.fromInt s.text.size ++ "px"
3257
+ , Attr.textAnchor "middle"
3258
+ , Attr.dominantBaseline
3259
+ (if (s.text.label |> String.toList |> List.foldl (\c b -> b && Char.isAlphaNum c) True) && String.toLower s.text.label == s.text.label then
3260
+ "middle"
3261
+
3262
+ else
3263
+ "central"
3264
+ )
3265
+ ]
3266
+ [ Svg.text s.text.label ]
3267
+ ]
3268
+ )
3269
+
3270
+
3271
+ stampsView : Model -> Svg Msg
3272
+ stampsView model =
3273
+ model.project.stamps
3274
+ |> List.filter
3275
+ (cull model.project Project.Stamp)
3276
+ |> List.map
3277
+ (\s ->
3278
+ stampView model s
3279
+ )
3280
+ |> Svg.Keyed.node "g" [ viewportAttr model, Attr.id "stamps" ]
3281
+
3282
+
3283
+ incompleteArrow : Model -> Svg Msg
3284
+ incompleteArrow model =
3285
+ case model.dragState of
3286
+ Nothing ->
3287
+ Svg.g [] []
3288
+
3289
+ Just { state, mode } ->
3290
+ case state.shape of
3291
+ Nothing ->
3292
+ Svg.g [] []
3293
+
3294
+ Just shape ->
3295
+ case mode of
3296
+ Connect ->
3297
+ let
3298
+ pos =
3299
+ state.start |> Vec.add state.offset
3300
+ in
3301
+ let
3302
+ ( rect, _, _ ) =
3303
+ shapeTransform model.project shape
3304
+ in
3305
+ if Rect.hasPoint pos <| rect then
3306
+ Svg.g [] []
3307
+
3308
+ else
3309
+ let
3310
+ points =
3311
+ arrowPointsToPos model.project shape pos
3312
+ in
3313
+ Svg.Keyed.node "g"
3314
+ [ viewportAttr model
3315
+ , Attr.pointerEvents "none"
3316
+ ]
3317
+ [ arrowView model points (makeArrow model.arrowPreset (shapeId shape) "" "") ]
3318
+
3319
+ _ ->
3320
+ Svg.g [] []
3321
+
3322
+
3323
+ getColor : Model -> Color -> String
3324
+ getColor m c =
3325
+ case c of
3326
+ Transparent ->
3327
+ "transparent"
3328
+
3329
+ _ ->
3330
+ "var(--" ++ colorAsString c ++ ")"
3331
+
3332
+
3333
+
3334
+ -- Palette.getColor m.palette c
3335
+
3336
+
3337
+ customFont : Bool -> H.Attribute msg
3338
+ customFont primary =
3339
+ HA.style "font-family" <|
3340
+ "var(--desck-"
3341
+ ++ (if primary then
3342
+ "primary"
3343
+
3344
+ else
3345
+ "secondary"
3346
+ )
3347
+ ++ "-font)"
3348
+
3349
+
3350
+ zoneView : Model -> Bool -> Zone -> ( String, Html Msg )
3351
+ zoneView model active z =
3352
+ let
3353
+ ( r, pivot, angle ) =
3354
+ draggedTransform model (Project.Zone z)
3355
+
3356
+ border =
3357
+ Vec.scale (toFloat <| z.border.width // 2) Vec.one
3358
+
3359
+ rect =
3360
+ { r | pos = Vec.sub r.pos border, size = Vec.add r.size (Vec.scale 2.0 border) }
3361
+
3362
+ -- TODO make label pos dependent on border width
3363
+ textRectSize =
3364
+ rect.size
3365
+
3366
+ ( textPos, ( textAnchor, dominantBaseline ), lineOffset ) =
3367
+ textAttributes textRectSize z.text
3368
+
3369
+ lines =
3370
+ String.split "\n" z.text.label
3371
+
3372
+ svgLines attrs =
3373
+ attrs
3374
+ |> List.map
3375
+ (\atts ->
3376
+ Svg.line
3377
+ (List.append atts
3378
+ [ Attr.stroke <| getColor model z.border.color
3379
+ , Attr.strokeWidth <| String.fromInt z.border.width
3380
+ , Attr.strokeDasharray <| String.fromInt z.border.dash
3381
+ ]
3382
+ )
3383
+ []
3384
+ )
3385
+
3386
+ horizontalLines w =
3387
+ if w > 1 then
3388
+ List.range 1 (w - 1)
3389
+ |> List.map
3390
+ (\x ->
3391
+ let
3392
+ cw =
3393
+ rect.size.x / toFloat w
3394
+
3395
+ px =
3396
+ toFloat x * cw
3397
+ in
3398
+ [ num Attr.x1 <| px
3399
+ , num Attr.y1 <| 0
3400
+ , num Attr.x2 <| px
3401
+ , num Attr.y2 <| rect.size.y
3402
+ ]
3403
+ )
3404
+ |> svgLines
3405
+
3406
+ else
3407
+ []
3408
+
3409
+ verticalLines h =
3410
+ if h > 1 then
3411
+ List.range 1 (h - 1)
3412
+ |> List.map
3413
+ (\y ->
3414
+ let
3415
+ cw =
3416
+ rect.size.y / toFloat h
3417
+
3418
+ py =
3419
+ toFloat y * cw
3420
+ in
3421
+ [ num Attr.x1 <| 0
3422
+ , num Attr.y1 <| py
3423
+ , num Attr.x2 <| rect.size.x
3424
+ , num Attr.y2 <| py
3425
+ ]
3426
+ )
3427
+ |> svgLines
3428
+
3429
+ else
3430
+ []
3431
+ in
3432
+ ( z.id
3433
+ , Svg.g
3434
+ (List.concat
3435
+ [ [ Attr.transform <|
3436
+ pivotRotate pivot rect.pos rect.size angle
3437
+ ++ translateXY rect.pos
3438
+ ]
3439
+ , if active then
3440
+ [ preventContextMenu
3441
+ , dragEvent z.id
3442
+ , onMouseUp EndDrag
3443
+
3444
+ -- , E.onDoubleClick (Mod (Project.Remove model.selected))
3445
+ ]
3446
+
3447
+ else
3448
+ [ Attr.pointerEvents "none" ]
3449
+ ]
3450
+ )
3451
+ <|
3452
+ List.concat
3453
+ [ [ Svg.rect
3454
+ [ num Attr.width rect.size.x
3455
+ , num Attr.height rect.size.y
3456
+ , Attr.rx cornerRadius
3457
+ , Attr.class "zone"
3458
+ , Attr.fill <| getColor model z.fill
3459
+ , Attr.stroke <| getColor model z.border.color
3460
+ , Attr.strokeWidth <| String.fromInt z.border.width
3461
+ , Attr.strokeDasharray <| String.fromInt z.border.dash
3462
+ ]
3463
+ []
3464
+ ]
3465
+ , List.append
3466
+ (if z.grid.columns > 1 then
3467
+ horizontalLines z.grid.columns
3468
+
3469
+ else
3470
+ []
3471
+ )
3472
+ (if z.grid.rows > 1 then
3473
+ verticalLines z.grid.rows
3474
+
3475
+ else
3476
+ []
3477
+ )
3478
+ , [ Svg.text_
3479
+ [ Attr.class "zone-label"
3480
+ , Attr.pointerEvents "none"
3481
+ , Attr.fontSize <| String.fromInt z.text.size
3482
+ , customFont z.text.primary
3483
+ , Attr.fill <| getColor model z.text.color
3484
+ , num Attr.x textPos.x
3485
+ , num Attr.y textPos.y
3486
+ , Attr.textAnchor textAnchor
3487
+ , Attr.dominantBaseline dominantBaseline
3488
+ ]
3489
+ (lines
3490
+ |> List.indexedMap
3491
+ (\i t ->
3492
+ Svg.tspan
3493
+ [ Attr.dy <| lineOffset i t
3494
+ , num Attr.x textPos.x
3495
+ , customFont z.text.primary
3496
+ ]
3497
+ [ Svg.text <|
3498
+ if t == "" then
3499
+ " "
3500
+
3501
+ else
3502
+ t
3503
+ ]
3504
+ )
3505
+ )
3506
+ , Svg.text_ [ HA.style "font-size" "32px", Attr.fill "magenta" ] [ Svg.text <| z.id ]
3507
+ |> maybeHtml model.debug
3508
+ ]
3509
+ ]
3510
+ )
3511
+
3512
+
3513
+ zonesView : Model -> Svg Msg
3514
+ zonesView model =
3515
+ model.project.zones
3516
+ |> List.filter
3517
+ (cull model.project Project.Zone)
3518
+ |> List.map (zoneView model model.unlockedLayers.zones)
3519
+ |> Svg.Keyed.node "g" [ viewportAttr model ]
3520
+
3521
+
3522
+ inactiveFilter : String
3523
+ inactiveFilter =
3524
+ "filter: brightness(50%)"
3525
+
3526
+
3527
+ draggedLeaderTransform : Model -> Shape -> ( Rect, Vec2, Float )
3528
+ draggedLeaderTransform m s =
3529
+ let
3530
+ trans =
3531
+ shapeTransform m.project s
3532
+
3533
+ ( rect, pivot, angle ) =
3534
+ trans
3535
+ in
3536
+ case m.dragState of
3537
+ Nothing ->
3538
+ trans
3539
+
3540
+ Just { state, mode } ->
3541
+ case mode of
3542
+ Move ->
3543
+ ( { rect
3544
+ | pos =
3545
+ Vec.add rect.pos state.offset
3546
+ |> snapPos m
3547
+ }
3548
+ , pivot
3549
+ , angle
3550
+ )
3551
+
3552
+ Turn ->
3553
+ ( rect, pivot, angle + state.offset.x )
3554
+
3555
+ _ ->
3556
+ trans
3557
+
3558
+
3559
+
3560
+ -- shapeOffset : Model -> Shape -> Shape -> (Rect, Vec2, Float)
3561
+ -- shapeOffset m from to =
3562
+ -- let
3563
+ -- (frect, )
3564
+
3565
+
3566
+ draggedFollowerTransform : Model -> Drag -> Shape -> Shape -> ( Rect, Vec2, Float )
3567
+ draggedFollowerTransform m drag leader follower =
3568
+ let
3569
+ ( lrect, _, _ ) =
3570
+ shapeTransform m.project leader
3571
+
3572
+ ( ldrect, _, _ ) =
3573
+ draggedLeaderTransform m leader
3574
+
3575
+ offset =
3576
+ Vec.sub rect.pos lrect.pos
3577
+
3578
+ trans =
3579
+ shapeTransform m.project follower
3580
+
3581
+ ( rect, pivot, angle ) =
3582
+ trans
3583
+ in
3584
+ case drag.mode of
3585
+ Move ->
3586
+ ( { rect | pos = Vec.add ldrect.pos offset }, pivot, angle )
3587
+
3588
+ Turn ->
3589
+ ( rect, pivot, angle + drag.state.offset.x )
3590
+
3591
+ _ ->
3592
+ trans
3593
+
3594
+
3595
+ draggedTransform : Model -> Shape -> ( Rect, Vec2, Float )
3596
+ draggedTransform m s =
3597
+ let
3598
+ affected =
3599
+ case s of
3600
+ Project.Arrow a ->
3601
+ List.member a.id m.selected
3602
+ || List.member a.from m.selected
3603
+ || List.member a.to m.selected
3604
+
3605
+ _ ->
3606
+ List.member (shapeId s) m.selected
3607
+
3608
+ trans =
3609
+ shapeTransform m.project s
3610
+
3611
+ ( rect, pivot, angle ) =
3612
+ trans
3613
+ in
3614
+ if not affected then
3615
+ trans
3616
+
3617
+ else
3618
+ case m.dragState of
3619
+ Nothing ->
3620
+ trans
3621
+
3622
+ Just { state, mode } ->
3623
+ case mode of
3624
+ Move ->
3625
+ case state.shape of
3626
+ Just l ->
3627
+ if shapeId l == shapeId s then
3628
+ draggedLeaderTransform m s
3629
+
3630
+ else
3631
+ draggedFollowerTransform m { state = state, mode = mode } l s
3632
+
3633
+ Nothing ->
3634
+ ( { rect | pos = Vec.add rect.pos state.offset }, pivot, angle )
3635
+
3636
+ Turn ->
3637
+ ( rect, pivot, angle + state.offset.x )
3638
+
3639
+ _ ->
3640
+ trans
3641
+
3642
+
3643
+ cardView : Model -> Card -> ( String, Svg Msg )
3644
+ cardView m card =
3645
+ let
3646
+ { model, front, id, highlight } =
3647
+ card
3648
+
3649
+ ( rect, pivot, angle ) =
3650
+ draggedTransform m (Project.Card card)
3651
+ in
3652
+ ( id
3653
+ , Svg.g
3654
+ [ preventContextMenu
3655
+ , dragEvent id
3656
+ , onMouseUp EndDrag
3657
+
3658
+ -- TODO card shadow settings
3659
+ -- , Attr.filter "url(#shadow)"
3660
+ -- , E.onDoubleClick (Mod (Project.Remove m.selected))
3661
+ , Attr.class "card"
3662
+ , Attr.class
3663
+ (if not card.highlight then
3664
+ "card-disabled"
3665
+
3666
+ else
3667
+ ""
3668
+ )
3669
+ , HA.attribute "data-using" (encode 0 (list string (modelUsing model)))
3670
+ , Attr.style
3671
+ (if highlight then
3672
+ ""
3673
+
3674
+ else
3675
+ inactiveFilter
3676
+ )
3677
+ , Attr.transform <|
3678
+ pivotRotate pivot rect.pos rect.size angle
3679
+ ++ translateXY rect.pos
3680
+ ]
3681
+ <|
3682
+ List.concat
3683
+ [ if front then
3684
+ frontface rect.size.x rect.size.y model
3685
+
3686
+ else
3687
+ backface rect.size.x rect.size.y model
3688
+ , [ Svg.text_ [ HA.style "font-size" "32px", Attr.fill "magenta" ] [ Svg.text <| card.id ]
3689
+ |> maybeHtml m.debug
3690
+ ]
3691
+ ]
3692
+ )
3693
+
3694
+
3695
+ preventContextMenu : Svg.Attribute Msg
3696
+ preventContextMenu =
3697
+ Svg.Events.preventDefaultOn "contextmenu" (Decode.succeed ( Noop "", True ))
3698
+
3699
+
3700
+ removeOnExport : Svg.Attribute msg
3701
+ removeOnExport =
3702
+ Attr.class "remove-on-export"
3703
+
3704
+
3705
+ background : Model -> Svg Msg
3706
+ background model =
3707
+ let
3708
+ id =
3709
+ "background"
3710
+ in
3711
+ Svg.g
3712
+ [ Attr.class
3713
+ (if model.project.background == Transparent then
3714
+ "remove-on-export"
3715
+
3716
+ else
3717
+ ""
3718
+ )
3719
+ ]
3720
+ [ Svg.rect
3721
+ [ Attr.id id
3722
+ , Attr.strokeWidth (String.fromFloat <| model.project.viewport.size.x * 0.005)
3723
+ , num Attr.x model.project.viewport.pos.x
3724
+ , num Attr.y model.project.viewport.pos.y
3725
+ , num Attr.width model.project.viewport.size.x
3726
+ , num Attr.height model.project.viewport.size.y
3727
+ , Attr.fill <| getColor model model.project.background
3728
+ , viewportAttr model
3729
+ ]
3730
+ []
3731
+ , Svg.rect
3732
+ [ Attr.strokeWidth (String.fromFloat <| model.project.viewport.size.x * 0.005)
3733
+ , num Attr.x model.project.viewport.pos.x
3734
+ , num Attr.y model.project.viewport.pos.y
3735
+ , num Attr.width model.project.viewport.size.x
3736
+ , num Attr.height model.project.viewport.size.y
3737
+ , Attr.fill "none"
3738
+ , Attr.stroke "#777"
3739
+ , removeOnExport
3740
+ , viewportAttr model
3741
+ ]
3742
+ []
3743
+ ]