module Myxine.Reactive ( Reactive, ReactiveM, reactive, title, markup, on', on, Propagation(..), (@@), (##), target, this ) where import Text.Blaze.Html5 (Html, ToMarkup(..), string, (!), dataAttribute) import Text.Blaze.Renderer.Text import Text.Blaze.Internal (Attributable) import Data.String import Data.List (intercalate) import Control.Monad.State import Control.Monad.Reader import Data.Monoid import Data.Text (Text) import Data.List.NonEmpty (NonEmpty(..), (<|)) import qualified Data.List.NonEmpty as NonEmpty import qualified Data.Text.Lazy as Text import Control.Spoon (teaspoonWithHandles) import qualified Control.Exception as Exception import Control.Lens hiding ((<|)) import Myxine.Event import qualified Myxine.Direct as Direct (PageContent, pageBody, pageTitle) import Myxine.Handlers -- | The builder state for a reactive component. data ReactiveBuilder model = ReactiveBuilder { location :: !(NonEmpty Word) -- ^ The current location in the tree of listener scopes we've created. -- Calls to 'on' refer to the enclosing scope (that is, the tail of this -- list). , handlers :: !(Handlers model) -- ^ The accumulated handlers for events built up so far. , pageMarkup :: !Html -- ^ The accumulated markup for the page built up so far. , pageTitle :: !(Last Text) -- ^ The most-recently set title for the page (via 'title'). } -- | The underlying builder monad for the 'Reactive' type. -- -- This is almost always used with a return type of @()@, hence you will usually -- see it aliased as 'Reactive' @model@. newtype ReactiveM model a = ReactiveM (ReaderT model (State (ReactiveBuilder model)) a) deriving newtype (Functor, Applicative, Monad, MonadReader model) -- | The 'Reactive' type interleaves the description of page markup with the -- specification of event handlers for the page. -- -- It is a 'Monoid' and its underlying type 'ReactiveM' is a 'Monad', which -- means that just like the Blaze templating library, it can be (and is designed -- to be!) used in @do@-notation. Importantly, it is also a 'MonadReader' -- @model@, where the current model is returned by 'ask'. type Reactive model = ReactiveM model () -- | Wrap an inner reactive component in some enclosing HTML. Any listeners -- created via 'on' in the wrapped component will be scoped to only events that -- occur within this chunk of HTML. -- -- In the following example, we install a 'Click' handler for the whole page, -- then build a @
@ with /another/ 'Click' handler inside that page, which -- returns 'Stop' from 'on'' to stop the 'Click' event from bubbling out to the -- outer handler when the inner @
@ is clicked. -- -- @ -- do 'on' 'Click' $ \\_ -> -- liftIO $ putStrLn "Clicked outside!" -- div ! style "background: lightblue;" '@@' do -- "Click here, or elsewhere..." -- 'on'' 'Click' $ \\_ -> do -- liftIO $ putStrLn "Clicked inside!" -- pure 'Stop' -- @ (@@) :: (Html -> Html) -> ReactiveM model a -> ReactiveM model a wrap @@ ReactiveM inner = ReactiveM do builder <- get model <- ask let originalLoc = location builder (result, builder') = runState (runReaderT inner model) builder { location = 0 <| originalLoc -- descend a level in location cursor , pageMarkup = mempty -- handlers & pageTitle are threaded through and preserved } put builder' { location = let (h :| t) = originalLoc in (h + 1 :| t) -- move sideways , pageMarkup = do pageMarkup builder wrapInTarget originalLoc $ wrap (pageMarkup builder') -- handlers & pageTitle are threaded through and preserved } pure result where wrapInTarget :: NonEmpty Word -> Html -> Html wrapInTarget loc = (! dataAttribute clientDataAttr (showLoc loc)) infixr 5 @@ -- | Create a scope for event listeners without wrapping any enclosing HTML. Any -- listeners created via 'on' will apply only to HTML that is written inside -- this block, rather than to the enclosing broader scope. -- -- >>> target === (id @@) target :: ReactiveM model a -> ReactiveM model a target = (id @@) -- | Return a piece of JavaScript code which looks up the object corresponding -- to the current scope's location in the page. This is suitable to be used in -- 'eval', for instance, to retrieve properties of a particular element. -- -- If there is no enclosing '@@', then this is the @window@ object; otherwise, -- it is the outermost HTML element object created by the first argument to the -- enclosing '@@'. If there are multiple elements at the root of the enclosing -- '@@', then the first of these is selected. -- -- For example, here's an input which reports its own contents: -- -- @ -- textbox :: Reactive Text -- textbox = input @@ do -- e <- this -- on Input \_ -> do -- value <- eval $ this <> ".value" -- put value -- @ this :: ReactiveM model Text this = ReactiveM do loc <- gets (NonEmpty.tail . location) pure case loc of [] -> "window" h : t -> let selector = "[data-" <> clientDataAttr <> "=\"" <> showLoc (h :| t) <> "\"]" in "(document.querySelector('" <> selector <> "'))" -- | Write an atomic piece of HTML (or anything that can be converted to it) to -- the page in this location. Event listeners for its enclosing scope can be -- added by sequential use of 'on'. If you need sub-pieces of this HTML to have -- their own scoped event listeners, use '@@' to build a composite component. -- -- >>> markup h === const (toMarkup h) @@ pure () markup :: ToMarkup h => h -> Reactive model markup h = const (toMarkup h) @@ pure () -- | Set the title for the page. If this function is called multiple times in -- one update, the most recent call is used. title :: Text -> Reactive model title t = ReactiveM (modify \wb -> wb { pageTitle = Last (Just t) }) -- | Listen to a particular event and react to it by modifying the model for the -- page. This function's returned 'Propagation' value specifies whether or not -- to propagate the event outwards to other enclosing contexts. The event target -- is scoped to the enclosing '@@', or the whole page if at the top level. -- -- When the specified 'EventType' occurs, the event handler will be called with -- that event type's corresponding property record, e.g. a 'Click' event's -- handler will receive a 'MouseEvent' record. A handler can modify the page's -- model via 'State'ful actions and perform arbitrary IO using 'liftIO'. In the -- context of a running page, a handler also has access to the 'eval' and -- 'evalBlock' functions to evaluate JavaScript in that page. -- -- __Exception behavior:__ This function catches @PatternMatchFail@ exceptions -- thrown by the passed function. That is, if there is a partial pattern match -- in the pure function from event properties to stateful update, the stateful -- update will be silently skipped. This is useful as a shorthand to select only -- events of a certain sort, for instance: -- -- @ -- 'on'' 'Click' \\'MouseEvent'{shiftKey = True} -> -- do putStrLn "Shift + Click!" -- pure 'Bubble' -- @ on' :: EventType props -> (props -> StateT model IO Propagation) -> Reactive model on' event reaction = ReactiveM do loc <- gets (NonEmpty.tail . location) let selector = case loc of [] -> window (h : t) -> ("data-" <> clientDataAttr) `attrIs` Text.toStrict (Text.pack (showLoc (h :| t))) modify \builder -> builder { handlers = mappend (handlers builder) $ onEvent event [selector] $ \props model -> -- We need to do a pure and impure catch, because GHC might -- decide to inline things inside the IO action, or it might -- not! So we check in both circumstances. case tryMatch (runStateT (reaction props) model) of Nothing -> pure (Bubble, model) Just io -> do result <- Exception.try @Exception.PatternMatchFail io case result of Left _ -> pure (Bubble, model) Right update -> pure update } where tryMatch = teaspoonWithHandles [Exception.Handler \(_ :: Exception.PatternMatchFail) -> pure Nothing] -- | Listen to a particular event and react to it by modifying the model for the -- page. This is a special case of 'on'' where the event is always allowed to -- bubble out to listeners in enclosing contexts. -- -- See the documentation for 'on''. on :: EventType props -> (props -> StateT model IO ()) -> Reactive model on event action = on' event (\props -> action props >> pure Bubble) -- | Focus a reactive page fragment to manipulate a piece of a larger model, -- using a 'Traversal'' to specify what part(s) of the larger model to -- manipulate. -- -- This is especially useful when creating generic components which can be -- re-used in the context of many different models. For instance, we can define -- a toggle button and specify separately which part of a model it toggles: -- -- @ -- toggle :: 'Reactive' Bool -- toggle = -- button '@@' do -- active <- 'ask' -- if active then \"ON\" else \"OFF\" -- 'on' 'Click' \\_ -> 'modify' not -- -- twoToggles :: 'Reactive' (Bool, Bool) -- twoToggles = do -- _1 '##' toggle -- _2 '##' toggle -- @ -- -- This function takes a 'Traversal'', which is strictly more general than a -- 'Lens''. This means you can use traversals with zero or more than one target, -- and this many replicas of the given 'Reactive' fragment will be generated, -- each separately controlling its corresponding portion of the model. This -- means the above example could also be phrased: -- -- @ -- twoToggles :: 'Reactive' (Bool, Bool) -- twoToggles = 'each' '##' toggle -- @ (##) :: Traversal' model model' -> Reactive model' -> Reactive model l ## ReactiveM action = ReactiveM $ ReaderT \model -> iforOf_ (indexing l) model \i model' -> StateT \b@ReactiveBuilder{handlers = priorHandlers} -> let b' = flip execState (b {handlers = mempty}) $ runReaderT action model' in Identity $ ((), b' { handlers = priorHandlers <> focusHandlers (indexing l . index i) (handlers b') }) -- TODO: make this more efficient using a pre-applied Traversal? infixr 5 ## -- | Evaluate a reactive component to produce a pair of 'Direct.PageContent' and -- 'Handlers'. This is the bridge between the 'Direct.runPage' abstraction and -- the 'Reactive' abstraction: use this to run a reactive component in a -- 'Myxine.Page'. reactive :: Reactive model -> model -> (Direct.PageContent, Handlers model) reactive (ReactiveM action) model = let ReactiveBuilder{handlers, pageMarkup, pageTitle = pageContentTitle} = execState (runReaderT action model) initialBuilder pageContentBody = renderMarkup pageMarkup in (Direct.pageBody (Text.toStrict pageContentBody) <> foldMap Direct.pageTitle pageContentTitle, handlers) where initialBuilder = ReactiveBuilder { location = (0 :| []) , handlers = mempty , pageMarkup = pure () , pageTitle = Last Nothing } -- | 'Reactive' pages can be combined using '<>', which concatenates their HTML -- content and merges their sets of 'Handlers'. instance Semigroup a => Semigroup (ReactiveM model a) where m <> n = (<>) <$> m <*> n -- | The empty 'Reactive' page, with no handlers and no content, is 'mempty'. instance Monoid a => Monoid (ReactiveM model a) where mempty = pure mempty -- | You can apply an HTML attribute to any 'Reactive' page using '!'. instance Attributable (ReactiveM model a) where w ! a = (! a) @@ w -- | You can apply an HTML attribute to any function between 'Reactive' pages -- using '!'. This is useful when building re-usable widget libraries, allowing -- their attributes to be modified after the fact but before they are filled -- with contents. instance Attributable (ReactiveM model a -> ReactiveM model a) where f ! a = (! a) . f -- | A string literal is a 'Reactive' page containing that selfsame text. instance (a ~ ()) => IsString (ReactiveM model a) where fromString = markup . string -- | The in-browser name for the data attribute holding our tracking id. This is -- not the same as the @id@ attribute, because this means the user is free to -- use the _real_ @id@ attribute as they please. clientDataAttr :: IsString a => a clientDataAttr = "myxine-client-widget-id" -- | Helper function to show a location in the page: add hyphens between every -- number. showLoc :: IsString a => (NonEmpty Word) -> a showLoc = fromString . intercalate "-" . map show . NonEmpty.toList