diff --git a/client/elm.json b/client/elm.json index 0970c65..cd12888 100644 --- a/client/elm.json +++ b/client/elm.json @@ -1,33 +1,37 @@ { - "type": "application", - "source-directories": ["./src/elm", "./elm-material/src/elm"], - "elm-version": "0.19.1", - "dependencies": { - "direct": { - "NoRedInk/elm-json-decode-pipeline": "1.0.0", - "elm/browser": "1.0.2", - "elm/bytes": "1.0.8", - "elm/core": "1.0.5", - "elm/file": "1.0.5", - "elm/html": "1.0.0", - "elm/http": "2.0.0", - "elm/json": "1.1.3", - "elm/time": "1.0.0", - "elm/url": "1.0.0", - "elm-community/list-extra": "8.2.4", - "lattyware/elm-fontawesome": "4.0.0", - "lattyware/elm-json-diff": "1.0.0", - "norpan/elm-html5-drag-drop": "3.1.4", - "norpan/elm-json-patch": "1.0.1" + "type": "application", + "source-directories": [ + "./src/elm", + "./elm-material/src/elm" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "NoRedInk/elm-json-decode-pipeline": "1.0.0", + "elm/browser": "1.0.2", + "elm/bytes": "1.0.8", + "elm/core": "1.0.5", + "elm/file": "1.0.5", + "elm/html": "1.0.0", + "elm/http": "2.0.0", + "elm/json": "1.1.3", + "elm/time": "1.0.0", + "elm/url": "1.0.0", + "elm-community/list-extra": "8.2.4", + "jinjor/elm-diff": "1.0.6", + "lattyware/elm-fontawesome": "4.0.0", + "lattyware/elm-json-diff": "1.0.0", + "norpan/elm-html5-drag-drop": "3.1.4", + "norpan/elm-json-patch": "1.0.1" + }, + "indirect": { + "elm/regex": "1.0.0", + "elm/svg": "1.0.1", + "elm/virtual-dom": "1.0.2" + } }, - "indirect": { - "elm/regex": "1.0.0", - "elm/svg": "1.0.1", - "elm/virtual-dom": "1.0.2" + "test-dependencies": { + "direct": {}, + "indirect": {} } - }, - "test-dependencies": { - "direct": {}, - "indirect": {} - } } diff --git a/client/manydecks-client.img b/client/manydecks-client.img deleted file mode 100644 index b17dc8d..0000000 Binary files a/client/manydecks-client.img and /dev/null differ diff --git a/client/src/elm/Cards/Call.elm b/client/src/elm/Cards/Call.elm index 372f63d..1ee83aa 100644 --- a/client/src/elm/Cards/Call.elm +++ b/client/src/elm/Cards/Call.elm @@ -110,12 +110,9 @@ encode (Call call) = editor : Call -> Editor.Model editor (Call lines) = { atoms = lines |> List.map (List.concatMap partToAtoms) |> List.intersperse [ NewLine ] |> List.concat - , selection = Nothing + , selection = { start = 0, end = 0 } , selecting = Nothing , moving = Nothing - , hover = Nothing - , cursor = 0 - , styled = [] , control = False } @@ -124,7 +121,7 @@ partToAtoms : Part -> List Editor.Atom partToAtoms part = case part of Part.Text text style -> - text |> String.toList |> List.map Editor.Letter + text |> String.toList |> List.map (\c -> Editor.Letter c style) Part.Slot transform style -> [ Editor.Slot transform style ] @@ -151,10 +148,10 @@ atomsToParts ( f, r ) = group a b = case a of - Letter _ -> + Letter _ styleA -> case b of - Letter _ -> - True + Letter _ styleB -> + styleA == styleB _ -> False @@ -164,7 +161,7 @@ atomsToParts ( f, r ) = toChar atom = case atom of - Letter char -> + Letter char _ -> Just char _ -> @@ -172,11 +169,11 @@ atomsToParts ( f, r ) = toPart ( first, rest ) = case first of - Letter _ -> + Letter _ style -> (first :: rest) |> List.filterMap toChar |> String.fromList - |> (\t -> Part.Text t Style.None |> Just) + |> (\t -> Part.Text t style |> Just) Slot transform style -> Part.Slot transform style |> Just diff --git a/client/src/elm/ManyDecks.elm b/client/src/elm/ManyDecks.elm index 360d40d..e6b3237 100644 --- a/client/src/elm/ManyDecks.elm +++ b/client/src/elm/ManyDecks.elm @@ -93,7 +93,7 @@ subscriptions model = onUrlRequest : Browser.UrlRequest -> Msg -onUrlRequest urlRequest = +onUrlRequest _ = NoOp @@ -114,10 +114,10 @@ update msg model = SetError error -> case error of Error.Transient Error.AuthFailure -> - ( model, Route.redirectTo Login model.navKey ) + ( { model | auth = Nothing }, Route.redirectTo Login model.navKey ) Error.User Error.NotAuthenticated -> - ( model, Route.redirectTo Login model.navKey ) + ( { model | auth = Nothing }, Route.redirectTo Login model.navKey ) _ -> ( { model | error = Just error }, Cmd.none ) diff --git a/client/src/elm/ManyDecks/Pages/Decks.elm b/client/src/elm/ManyDecks/Pages/Decks.elm index c02234e..c9b46e6 100644 --- a/client/src/elm/ManyDecks/Pages/Decks.elm +++ b/client/src/elm/ManyDecks/Pages/Decks.elm @@ -87,7 +87,9 @@ update msg model = newDecks = model.decks |> Maybe.map (List.filter (\d -> d.code /= code)) in - ( { model | decks = newDecks, edit = Nothing }, GlobalRoute.redirectTo (Route.Decks Route.List) model.navKey ) + ( { model | decks = newDecks, edit = Nothing } + , GlobalRoute.redirectTo (Route.Decks Route.List) model.navKey + ) Save code patch -> case model.auth of diff --git a/client/src/elm/ManyDecks/Pages/Decks/Edit.elm b/client/src/elm/ManyDecks/Pages/Decks/Edit.elm index b70eae9..8b435f8 100644 --- a/client/src/elm/ManyDecks/Pages/Decks/Edit.elm +++ b/client/src/elm/ManyDecks/Pages/Decks/Edit.elm @@ -75,15 +75,19 @@ update msg model = UpdateCall callEditorMsg -> let - editing = + ( editing, cmd ) = case model.editing of Just (CallEditor index old m) -> - m |> CallEditor.update callEditorMsg |> CallEditor index old |> Just + let + ( e, eCmd ) = + m |> CallEditor.update callEditorMsg + in + ( e |> CallEditor index old |> Just, eCmd ) other -> - other + ( other, Cmd.none ) in - ( { model | editing = editing }, Cmd.none ) + ( { model | editing = editing }, cmd ) StartEditing cardEditor -> let @@ -217,18 +221,13 @@ view code model = d = IconButton.view (Icon.trash |> Icon.viewIcon) "Delete" (Delete |> wrap |> Just) - ( problems, addSlot ) = + problems = case callEditor of Just editorModel -> - ( CallEditor.problems editorModel - , IconButton.view (Icon.plusCircle |> Icon.viewIcon) - "Add Slot" - (CallEditor.AddSlot |> UpdateCall |> Edit |> wrap |> Just) - |> Just - ) + CallEditor.problems editorModel _ -> - ( [], Nothing ) + [] noProblems = List.isEmpty problems @@ -242,24 +241,12 @@ view code model = s = IconButton.view (Icon.save |> Icon.viewIcon) "Save" sAction - - controlsContents = - [ Just d, addSlot, Just s ] |> List.filterMap identity - - problemsView = - if noProblems then - Html.text "" - - else - Html.ul [ HtmlA.class "problems" ] - (problems |> List.map (\p -> Html.li [] [ Html.text p ])) in [ Html.div [ HtmlA.class "overlay" ] [ Html.div [ HtmlA.class "background", EndEditing |> wrap |> HtmlE.onClick ] [] , Card.view [] [ Html.div [ HtmlA.class "editing" ] c - , problemsView - , Html.div [ HtmlA.class "editing-controls" ] controlsContents + , Html.div [ HtmlA.class "editing-controls" ] [ d, s ] ] ] ] diff --git a/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor.elm b/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor.elm index d7d3cef..2892344 100644 --- a/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor.elm +++ b/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor.elm @@ -5,17 +5,22 @@ module ManyDecks.Pages.Decks.Edit.CallEditor exposing , view ) -import Browser.Events as Browser import Cards.Call as Call -import Cards.Call.Style as Style -import Cards.Call.Transform as Transform +import Cards.Call.Style as Style exposing (Style) +import Cards.Call.Transform as Transform exposing (Transform) import Cards.Card as Card +import Diff +import FontAwesome.Icon as Icon +import FontAwesome.Layering as Icon +import FontAwesome.Solid as Icon import Html exposing (Html) import Html.Attributes as HtmlA import Html.Events as HtmlE import Json.Decode as Json import List.Extra as List import ManyDecks.Pages.Decks.Edit.CallEditor.Model exposing (..) +import ManyDecks.Ports as Ports +import Material.IconButton as IconButton deleteSpan : Span -> Model -> Model @@ -27,7 +32,7 @@ deleteSpan { start, end } model = right = model.atoms |> List.drop end in - { model | atoms = List.concat [ left, right ], selection = Nothing, cursor = start } + { model | atoms = List.concat [ left, right ], selection = span start start } insertAt : List Atom -> Position -> Model -> Model @@ -38,160 +43,19 @@ insertAt new position model = atoms = List.concat [ left, new, right ] + + endPos = + position + List.length new in - { model | atoms = atoms, cursor = position + List.length new } + { model | atoms = atoms, selection = span endPos endPos } -moveCursor : (Position -> Position) -> Model -> Model -moveCursor move model = - let - afterLast = - List.length model.atoms - - cursor = - min afterLast (max (model.cursor |> move) 0) - - selection = - case model.selecting of - Just start -> - selectionOf start cursor - - Nothing -> - Nothing - in - { model | cursor = cursor, selection = selection } - - -moveRow : Int -> Model -> Model -moveRow diff model = - let - applyNTimes n f value = - if n > 0 then - value |> f |> applyNTimes (n - 1) f - - else - value - - atoms = - model.atoms - in - if diff > 0 then - model |> applyNTimes diff (moveCursor (\p -> p + toEndOfLine atoms p + 1)) - - else - model |> applyNTimes -diff (moveCursor (\p -> p - toStartOfLine atoms p - 1)) - - -toEndOfLine : List Atom -> Position -> Int -toEndOfLine atoms p = - atoms - |> List.drop p - |> List.findIndex ((==) NewLine) - |> Maybe.withDefault (List.length atoms) - - -toStartOfLine : List Atom -> Position -> Int -toStartOfLine atoms p = - let - pRev = - List.length atoms - p - in - atoms - |> List.reverse - |> List.drop pRev - |> List.findIndex ((==) NewLine) - |> Maybe.withDefault p - - -update : Msg -> Model -> Model +update : Msg -> Model -> ( Model, Cmd msg ) update msg model = case msg of - KeyUp key -> - case key of - Control "Shift" -> - { model | selecting = Nothing } - - Control "Control" -> - { model | control = False } - - _ -> - model - - KeyDown key -> - let - cursor = - model.cursor - - op = - case key of - Control "Delete" -> - case model.selection of - Nothing -> - deleteSpan (span cursor (cursor + 1)) - - Just selection -> - deleteSpan selection - - Control "Backspace" -> - case model.selection of - Nothing -> - deleteSpan (span (cursor - 1) cursor) - - Just selection -> - deleteSpan selection - - Control "Enter" -> - case model.selection of - Nothing -> - insertAt [ NewLine ] cursor - - Just selection -> - deleteSpan selection >> (\m -> insertAt [ NewLine ] m.cursor m) - - Control "ArrowLeft" -> - moveCursor (\c -> c - 1) - - Control "ArrowRight" -> - moveCursor (\c -> c + 1) - - Control "ArrowUp" -> - moveRow -1 - - Control "ArrowDown" -> - moveRow 1 - - Control "End" -> - moveCursor (\p -> p + toEndOfLine model.atoms p) - - Control "Home" -> - moveCursor (\p -> p - toStartOfLine model.atoms p) - - Control "Shift" -> - \m -> { m | selecting = Just m.cursor } - - Control "Control" -> - \m -> { m | control = True } - - Character char -> - if model.control then - identity - - else - case model.selection of - Nothing -> - insertAt [ Letter char ] cursor - - Just selection -> - deleteSpan selection >> insertAt [ Letter char ] selection.start - - _ -> - identity - in - op model - Enter position -> let - ( selection, cursor ) = + selection = case model.selecting of Just start -> let @@ -202,45 +66,35 @@ update msg model = else position + 1 in - ( selectionOf start end, end ) + span start end Nothing -> - let - c = - if model.moving /= Nothing then - position + if model.moving /= Nothing then + span position position - else - model.cursor - in - ( model.selection, c ) + else + model.selection in - { model | hover = Just position, selection = selection, cursor = cursor } + ( { model | selection = selection }, Ports.setCallInputGhostSelection selection ) Leave position -> - let - hover = - if model.hover == Just position then - Nothing - - else - model.hover - in - { model | hover = hover } + ( model, Cmd.none ) StartSelection position -> - { model - | selection = Nothing - , selecting = Just position - , cursor = position - } + let + selection = + span position position + in + ( { model | selection = selection, selecting = Just position } + , Ports.setCallInputGhostSelection selection + ) StartMoving position -> - { model | moving = Just position } + ( { model | moving = Just position }, Cmd.none ) EndSelection position -> let - ( s, cursor ) = + s = case model.selecting of Just start -> let @@ -251,19 +105,19 @@ update msg model = else position + 1 in - ( selectionOf start end, end ) + span start end Nothing -> - ( Nothing, position ) + span position position m = - { model | selection = s, selecting = Nothing, cursor = cursor } + { model | selection = s, selecting = Nothing } newModel = case m.moving of Just from -> if from == position then - { m | selection = span from (from + 1) |> Just } + { m | selection = span from (from + 1) } else let @@ -287,18 +141,98 @@ update msg model = Nothing -> m in - { newModel | moving = Nothing } + ( { newModel | moving = Nothing }, Ports.setCallInputGhostSelection newModel.selection ) AddSlot -> - model |> insertAt [ Slot Transform.None Style.None ] model.cursor + let + newModel = + model + |> deleteSpan model.selection + |> insertAt [ Slot Transform.None Style.None ] model.selection.start + in + ( newModel, Ports.setCallInputGhostSelection newModel.selection ) + + SetStyle style -> + ( applyToSelection (setStyle style) model, Cmd.none ) + + SetTransform transform -> + let + setTransform atom = + case atom of + Slot _ s -> + Slot transform s + + _ -> + atom + in + ( applyToSelection setTransform model, Cmd.none ) + + UpdateFromGhost str -> + let + old = + model.atoms |> List.map atomToChar + + new = + str |> String.toList + + diff = + Diff.diff old new + + folder change ( input, output ) = + case change of + Diff.NoChange _ -> + case input of + first :: rest -> + ( rest, Just first :: output ) + + _ -> + ( input, output ) + + Diff.Added char -> + let + previousStyle = + case output of + first :: _ -> + first |> Maybe.andThen getStyle + + _ -> + Nothing + + setStyleIfPrevious = + previousStyle |> Maybe.map (\s -> setStyle s) |> Maybe.withDefault identity + in + ( input, (char |> charToAtom |> setStyleIfPrevious |> Just) :: output ) + + Diff.Removed _ -> + case input of + _ :: rest -> + ( rest, output ) + + _ -> + ( input, output ) + + ( _, updated ) = + List.foldl folder ( model.atoms, [] ) diff + in + ( { model | atoms = updated |> List.filterMap identity |> List.reverse }, Cmd.none ) + + GhostSelectionChanged selection -> + ( { model | selection = selection }, Cmd.none ) + + +applyToSelection : (Atom -> Atom) -> Model -> Model +applyToSelection f model = + { model | atoms = model.atoms |> applyToSpan model.selection f } + + +applyToSpan : Span -> (Atom -> Atom) -> List Atom -> List Atom +applyToSpan s f = + List.updateIfIndex (\i -> inSpan i s) f subscriptions : (Msg -> msg) -> Sub msg subscriptions wrap = - Sub.batch - [ Browser.onKeyDown (keyDecoder |> Json.map (KeyDown >> wrap)) - , Browser.onKeyUp (keyDecoder |> Json.map (KeyUp >> wrap)) - ] + Ports.getCallInputGhostSelection (GhostSelectionChanged >> wrap) lines : List Atom -> List (List ( Int, Atom )) @@ -311,10 +245,150 @@ lines = view : (Msg -> msg) -> Model -> Html msg view wrap model = let + stringVersion = + model.atoms + |> List.map atomToChar + |> String.fromList + + callInputGhost = + Html.textarea + [ HtmlA.id "call-input-ghost" + , HtmlA.value stringVersion + , HtmlE.onInput (UpdateFromGhost >> wrap) + , onSelect (GhostSelectionChanged >> wrap) + ] + [] + content = - model.atoms ++ [ NewLine ] |> lines |> List.map (viewLine wrap model) + callInputGhost :: (model.atoms ++ [ NewLine ] |> lines |> List.map (viewLine wrap model)) + + selection = + model.selection + + ( ( selectedStyle, styleEnabled ), ( selectedTransform, transformEnabled ) ) = + let + selected = + model.atoms |> List.drop selection.start |> List.take (selection.end - selection.start) + + allSame parts = + case parts |> List.filterMap identity of + first :: rest -> + if rest |> List.all ((==) first) then + Just first + + else + Nothing + + [] -> + Nothing + in + ( ( selected |> List.map getStyle |> allSame, True ) + , ( selected |> List.map getTransform |> allSame, selected |> List.any isSlot ) + ) + + buttonFor icon value flipIf disableValue description act enabled = + let + ( action, fullIcon ) = + if flipIf == Just value then + ( act disableValue, [ icon, Icon.slash |> Icon.viewIcon ] ) + + else + ( act value, [ icon ] ) + + finalAction = + if enabled then + Just action + + else + Nothing + in + IconButton.view (Icon.layers [] fullIcon) description finalAction + + controls = + Html.div [ HtmlA.class "call-controls" ] + [ IconButton.view (Icon.plusCircle |> Icon.viewIcon) + "Add Slot" + (AddSlot |> wrap |> Just) + , Html.div [] + [ buttonFor (Icon.text [] "Aa") + Transform.Capitalize + selectedTransform + Transform.None + "Capitalize" + (SetTransform >> wrap) + transformEnabled + , buttonFor (Icon.text [] "AA") + Transform.UpperCase + selectedTransform + Transform.None + "Uppercase" + (SetTransform >> wrap) + transformEnabled + ] + , Html.div [] + [ buttonFor (Icon.italic |> Icon.viewIcon) + Style.Em + selectedStyle + Style.None + "Emphasize" + (SetStyle >> wrap) + styleEnabled + ] + ] + + ps = + problems model + + problemsView = + if ps |> List.isEmpty then + Html.text "" + + else + Html.ul [ HtmlA.class "problems" ] + (ps |> List.map (\p -> Html.li [] [ Html.text p ])) in - Html.div [] [ Card.view Call.type_ Card.Immutable content Card.Face ] + Html.div [] + [ controls + , Card.view Call.type_ Card.Immutable content Card.Face + , problemsView + ] + + +onSelect : (Span -> msg) -> Html.Attribute msg +onSelect wrap = + let + decoder = + Json.map2 (\s e -> span s e |> wrap) + (Json.at [ "target", "selectionStart" ] Json.int) + (Json.at [ "target", "selectionEnd" ] Json.int) + in + HtmlE.on "select" decoder + + +atomToChar : Atom -> Char +atomToChar atom = + case atom of + Letter char _ -> + char + + Slot _ _ -> + '_' + + NewLine -> + '\n' + + +charToAtom : Char -> Atom +charToAtom char = + case char of + '_' -> + Slot Transform.None Style.None + + '\n' -> + NewLine + + _ -> + Letter char Style.None problems : Model -> List String @@ -326,18 +400,42 @@ problems model = [ "Calls must contain at least one slot." ] -selectionOf : Position -> Position -> Maybe Span -selectionOf start end = - if start /= end then - span start end |> Just - - else - Nothing - - viewLine : (Msg -> msg) -> Model -> List ( Int, Atom ) -> Html msg viewLine wrap model line = - Html.p [] (line |> List.map (viewAtom wrap model)) + Html.p [] (line |> clusterAtoms |> List.map (viewClusters (viewAtom wrap model))) + + +clusterAtoms : List ( a, Atom ) -> List ( ( a, Atom ), List ( a, Atom ) ) +clusterAtoms line = + let + isCluster ( _, a ) ( _, b ) = + case a of + Letter c _ -> + case b of + Letter _ _ -> + if c == ' ' then + False + + else + True + + _ -> + False + + _ -> + False + in + line |> List.groupWhile isCluster + + +viewClusters : (( Int, Atom ) -> Html msg) -> ( ( Int, Atom ), List ( Int, Atom ) ) -> Html msg +viewClusters v ( single, rest ) = + case rest of + [] -> + v single + + _ -> + Html.span [] (single :: rest |> List.map v) viewAtom : (Msg -> msg) -> Model -> ( Int, Atom ) -> Html msg @@ -348,28 +446,66 @@ viewAtom wrap model ( position, atom ) = , position |> Leave |> wrap |> HtmlE.onMouseLeave , position |> EndSelection |> wrap |> HtmlE.onMouseUp , HtmlA.classList - [ ( "cursor", model.cursor == position ) - , ( "selected", model.selection |> Maybe.map (inSpan position) |> Maybe.withDefault False ) + [ ( "cursor", model.selection == Span position position ) + , ( "selected", model.selection |> inSpan position ) ] ] in case atom of - Letter char -> - Html.span + Letter char style -> + Style.toNode style ([ position |> StartSelection |> wrap |> HtmlE.onMouseDown ] ++ attrs) [ char |> String.fromChar |> Html.text ] - Slot _ _ -> + Slot transform style -> let slotAttrs = - [ HtmlA.class "slot empty", position |> StartMoving |> wrap |> HtmlE.onMouseDown ] + HtmlA.class "slot empty" + :: (position |> StartMoving |> wrap |> HtmlE.onMouseDown) + :: Transform.toAttributes transform in - Html.span (slotAttrs ++ attrs) [] + Style.toNode style (slotAttrs ++ attrs) [] NewLine -> Html.span ([ HtmlA.class "spacer" ] ++ attrs) [] +getStyle : Atom -> Maybe Style +getStyle atom = + case atom of + Letter _ s -> + Just s + + Slot _ s -> + Just s + + _ -> + Nothing + + +setStyle : Style -> Atom -> Atom +setStyle style atom = + case atom of + Letter c _ -> + Letter c style + + Slot t _ -> + Slot t style + + _ -> + atom + + +getTransform : Atom -> Maybe Transform +getTransform atom = + case atom of + Slot t _ -> + Just t + + _ -> + Nothing + + span : Position -> Position -> Span span a b = if a < b then @@ -384,21 +520,6 @@ inSpan position { start, end } = position >= start && position < end -keyDecoder : Json.Decoder Key -keyDecoder = - Json.map toKey (Json.field "key" Json.string) - - -toKey : String -> Key -toKey string = - case String.uncons string of - Just ( char, "" ) -> - Character char - - _ -> - Control string - - isSlot : Atom -> Bool isSlot atom = case atom of diff --git a/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor/Model.elm b/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor/Model.elm index bf6c37d..825c79e 100644 --- a/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor/Model.elm +++ b/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor/Model.elm @@ -6,12 +6,9 @@ import Cards.Call.Transform exposing (Transform) type alias Model = { atoms : List Atom - , selection : Maybe Span + , selection : Span , selecting : Maybe Position , moving : Maybe Position - , hover : Maybe Position - , cursor : Position - , styled : List ( Span, Style ) , control : Bool } @@ -27,7 +24,7 @@ type alias Span = type Atom - = Letter Char + = Letter Char Style | Slot Transform Style | NewLine @@ -38,11 +35,8 @@ type Msg | StartSelection Position | EndSelection Position | StartMoving Position - | KeyDown Key - | KeyUp Key | AddSlot - - -type Key - = Character Char - | Control String + | SetStyle Style + | SetTransform Transform + | UpdateFromGhost String + | GhostSelectionChanged Span diff --git a/client/src/elm/ManyDecks/Ports.elm b/client/src/elm/ManyDecks/Ports.elm index 31d8de6..eb95a74 100644 --- a/client/src/elm/ManyDecks/Ports.elm +++ b/client/src/elm/ManyDecks/Ports.elm @@ -2,6 +2,7 @@ port module ManyDecks.Ports exposing (..) import Json.Decode as Json import ManyDecks.Auth as Auth +import ManyDecks.Pages.Decks.Edit.CallEditor.Model exposing (Span) port tryGoogleAuth : String -> Cmd msg @@ -20,3 +21,9 @@ port storeAuth : Maybe Auth.Auth -> Cmd msg port copy : String -> Cmd msg + + +port setCallInputGhostSelection : Span -> Cmd msg + + +port getCallInputGhostSelection : (Span -> msg) -> Sub msg diff --git a/client/src/elm/ManyDecks/index.d.ts b/client/src/elm/ManyDecks/index.d.ts index 4af1739..a3072a2 100644 --- a/client/src/elm/ManyDecks/index.d.ts +++ b/client/src/elm/ManyDecks/index.d.ts @@ -9,6 +9,11 @@ interface Flags { auth?: Auth; } +interface Span { + start: number; + end: number; +} + type GoogleAuthResult = { code: string } | { error: string }; export interface InboundPort { @@ -29,6 +34,8 @@ export namespace Elm { json5Decoded: OutboundPort; storeAuth: InboundPort; copy: InboundPort; + setCallInputGhostSelection: InboundPort; + getCallInputGhostSelection: OutboundPort; }; } export function init(options: { diff --git a/client/src/scss/_edit.scss b/client/src/scss/_edit.scss index f7f09b2..ee88f0a 100644 --- a/client/src/scss/_edit.scss +++ b/client/src/scss/_edit.scss @@ -123,6 +123,23 @@ flex-grow: 1; height: 1.2em; } + + #call-input-ghost { + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 0; + + z-index: -100; + opacity: 0; + pointer-events: none; + } +} + +.call-controls { + display: flex; + justify-content: space-between; } .editing-controls { diff --git a/client/src/scss/cards/_card.scss b/client/src/scss/cards/_card.scss index ecf3804..ee3ba08 100644 --- a/client/src/scss/cards/_card.scss +++ b/client/src/scss/cards/_card.scss @@ -49,7 +49,8 @@ justify-content: flex-start; margin: 0; - span { + span, + em { white-space: pre-wrap; overflow-wrap: break-word; word-break: break-all; diff --git a/client/src/ts/index.ts b/client/src/ts/index.ts index 9d5a304..8270663 100644 --- a/client/src/ts/index.ts +++ b/client/src/ts/index.ts @@ -69,6 +69,25 @@ const main = async () => { } } }); + + app.ports.setCallInputGhostSelection.subscribe(({ start, end }) => { + const element = document.getElementById("call-input-ghost"); + if (element !== null) { + const textarea = element as HTMLTextAreaElement; + textarea.focus(); + textarea.setSelectionRange(start, end); + if (!element.oninput) { + const updateSelection = () => { + app.ports.getCallInputGhostSelection.send({ + start: textarea.selectionStart, + end: textarea.selectionEnd, + }); + }; + element.oninput = updateSelection; + element.onkeydown = updateSelection; + } + } + }); }; main().catch(console.error); diff --git a/server/src/ts/index.ts b/server/src/ts/index.ts index 58ff592..b33e70f 100644 --- a/server/src/ts/index.ts +++ b/server/src/ts/index.ts @@ -99,7 +99,7 @@ const main = async (): Promise => { app.delete("/api/users", async (req, res) => { const claims = auth.validate(req.body.token); await store.deleteUser(claims.sub); - res.status(HttpStatus.OK); + res.status(HttpStatus.OK).json({}); }); app.get("/api/decks/:deckCode", async (req, res) => { @@ -156,7 +156,7 @@ const main = async (): Promise => { const id = Code.decode(req.params.deckCode); const claims = auth.validate(req.body.token); await store.deleteDeck(id, claims.sub); - res.status(HttpStatus.OK); + res.status(HttpStatus.OK).json({}); }); app.use(