Search code examples
elmkatex

Embedding mathematical equations in an Elm Spa


I want to put mathematical equations in a single page app written in elm. I would like the equations to be rendered in the app and not being embedded as prerendered images.

I tried to realize this using Katex for elm (https://package.elm-lang.org/packages/yotamDvir/elm-katex/latest/) but my approach has 3 major problems:

  • when the page is loaded initially the equations are not rendered
  • the rendering does not always work when a link is clicked
  • rendered math elements are preserved in page changes when a link is clicked, thus destroying the content of the page.

This is hoow it looks: Example of the 3 problems described above

Here is the code that I am using right now:

index.html

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>SPA with Math formulas</title>
  <style>
    body {
      padding: 0;
      margin: 0;
      background-color: #000000;
      color: #ffffff;
    }
  </style>
  <script src="main.js"></script>

  <style>
    /* LaTeX display environment will effect the LaTeX characters but not the layout on the page */
    span.katex-display {
      display: inherit;
      /* You may comment this out if you want the default behavior */
    }
  </style>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css"
    integrity="sha384-AfEj0r4/OFrOo5t7NnNe46zW/tFgW6x/bCJG8FqQCEo3+Aro6EYUG4+cU+KJWu/X" crossorigin="anonymous">
  <script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js"
    integrity="sha384-g7c+Jr9ZivxKLnZTDUhnkOnsh30B4H0rpLUpJ4jAIKs4fnJI+sEnkvrMWph2EDg4"
    crossorigin="anonymous"></script>
  <script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/contrib/auto-render.min.js"
    integrity="sha384-mll67QQFJfxn0IYznZYonOWZ644AWYC+Pt2cHqMaRhXVrursRwvLnLaebdGIlYNa" crossorigin="anonymous"
    onload="renderMathInElement(document.body);"></script>
</head>



<body>

  <div id="elm-main"> </div>
  <script>
    // Initialize your Elm program
    var app = Elm.Main.init({
      flags: location.href,
      node: document.getElementById('elm-main')
    });

    // Inform app of browser navigation (the BACK and FORWARD buttons)
    window.addEventListener('popstate', function () {
      app.ports.onUrlChange.send(location.href);
    });

    // Change the URL upon request, inform app of the change.
    app.ports.pushUrl.subscribe(function (url) {
      history.pushState({}, '', url);
      app.ports.onUrlChange.send(location.href);
    });

    // Render math texts in app
    app.ports.renderMath.subscribe(function () {
      renderMathInElement(document.body, {
        delimiters: [
          {
            left: "$begin-inline$",
            right: "$end-inline$",
            display: false
          },
          {
            left: "$begin-display$",
            right: "$end-display$",
            display: true
          }]
      });
    });
  </script>
  <noscript>
    This site needs javascript enabled in order to work.
  </noscript>
</body>

</html>

src/Main.elm

port module Main exposing (..)

import Browser exposing (Document, application)
import Element exposing (Attribute, Element)
import Element.Font
import Html
import Html.Attributes
import Html.Events
import Json.Decode as D
import Katex
import Url
import Url.Parser


main =
    Browser.document
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }


type Route
    = Home
    | Other


init : String -> ( Route, Cmd Msg )
init url =
    ( locationHrefToRoute url, Cmd.none )


type Msg
    = PushUrl Route
    | UrlChanged String


update : Msg -> Route -> ( Route, Cmd Msg )
update msg route =
    case msg of
        PushUrl newRoute ->
            ( route, pushUrl (stringFromRoute newRoute) )

        UrlChanged url ->
            ( locationHrefToRoute url, renderMath () )


stringFromRoute : Route -> String
stringFromRoute route =
    case route of
        Home ->
            "/"

        Other ->
            "/other"


locationHrefToRoute : String -> Route
locationHrefToRoute locationHref =
    case Url.fromString locationHref of
        Nothing ->
            Home

        Just url ->
            Maybe.withDefault Home (Url.Parser.parse routeParser url)


routeParser : Url.Parser.Parser (Route -> a) a
routeParser =
    Url.Parser.oneOf
        [ Url.Parser.map Home Url.Parser.top
        , Url.Parser.map Other (Url.Parser.s "other")
        ]


view : Route -> Document Msg
view route =
    { title = "SPA and Katex"
    , body =
        [ Element.layout
            [ Element.Font.color (Element.rgb 1 1 1)
            ]
            (Element.el
                [ Element.centerX, Element.centerY ]
                (viewPage route)
            )
        ]
    }


viewPage : Route -> Element Msg
viewPage route =
    case route of
        Home ->
            Element.column [ Element.spacing 20 ]
                [ link Other "Link to Other"
                , Element.text "some text"
                , "\\mathrm{home} =  6.2 \\times 10^{-34}"
                    |> Katex.inline
                    |> Katex.print
                    |> Element.text
                ]

        Other ->
            Element.column [ Element.spacing 20 ]
                [ link Home "Link to Home"
                , "\\mathrm{other} = 1.3 \\times 10^{-6}"
                    |> Katex.inline
                    |> Katex.print
                    |> Element.text
                , Element.text "other text"
                ]


linkBehaviour : Route -> Attribute Msg
linkBehaviour route =
    Element.htmlAttribute
        (Html.Events.preventDefaultOn "click"
            (D.succeed
                ( PushUrl route, True )
            )
        )


link : Route -> String -> Element Msg
link route labelText =
    Element.link
        [ linkBehaviour route
        , Element.Font.color (Element.rgb255 119 35 177)
        , Element.Font.underline
        ]
        { url = stringFromRoute route
        , label = Element.text labelText
        }


subscriptions : Route -> Sub Msg
subscriptions route =
    Sub.batch
        [ onUrlChange UrlChanged
        ]


port onUrlChange : (String -> msg) -> Sub msg


port pushUrl : String -> Cmd msg


port renderMath : () -> Cmd msg

I start my app with elm-live src/Main.elm -u --open -- --output=main.js --debug:


Solution

  • The elm-katex docs point out ports being unnecessary.

    No ports are necessary, but the KaTeX library must be loaded in the event loop. See §Loading KaTeX at the bottom for details.

    The Loading KaTeX section of the docs suggests listening for DOMContentLoaded, then calling KaTeX's auto-render extension. That said, KaTeX's auto-render extension works by editing the DOM, meaning it won't play nice with Elm.

    Rendering Tex math using KaTeX is a good use-case for a Custom Element (Web Components). It would be relatively straightforward to implement if you're familiar with custom elements but there's likely something already out there that will work.

    See the Custom Elements section of the Elm Guide for a good intro.

    I've not taken much of a look at it, but here's an option: navsgh/katex-expression

    Load katex-expression before you initialize your Elm code, then write something like this:

    import Html exposing (Html, node)
    import Html.Attributes exposing (attribute)
    import Json.Encode as Encode
    
    
    viewLatex : String -> Html msg
    viewLatex expr =
      node "katex-expression"
        [ attribute "expression" expr
        , attribute "katex-options" (Encode.encode 0 options)
        ]
        []
    
    
    options : Encode.Value
    options =
        Encode.object
            [ ( "displayMode", Encode.bool True )
            ]
    

    Here's a quick Ellie demo: https://ellie-app.com/m9HQrnXmydLa1