fast-reflexes / better-react-mathjax

MIT License
124 stars 16 forks source link

Is there a proper way to use this with tiny-mce editor? #38

Closed SoozaV closed 1 year ago

SoozaV commented 1 year ago

I'd like to add better-react-mathjax to my project using @tinymce/tinymce-react editor, is there a proper way to achieve this? I tried using @dimakorotkov/tinymce-mathjax plugin for tiny-mce but it only accepts LaTeX syntax.

Regards.

fast-reflexes commented 1 year ago

There was recently a question about using better-react-mathjax inside a SimpleMDE editor which ended successfully: https://github.com/fast-reflexes/better-react-mathjax/issues/35

I can try it out with tinymce and I'll let you know!

fast-reflexes commented 1 year ago

It wasn't too hard. Here is a code sandbox: https://codesandbox.io/s/better-react-mathjax-38-npbq6f

Since the content of TinyMCE is really displayed in an iFrame, we can't rely on the usual typesetting mechanism. The idea then is to do the typesetting manually instead. Because manually typesetting every time the content changes in the editor causes problems in the input / output (if we immediately convert MathJax entities on the fly, it will cause formatting issues and inputting is messed, try it yourself), my suggestion is to add a button which controls when the content is being typeset:

https://user-images.githubusercontent.com/2438128/215072932-bd9f5bb1-0ff7-4710-8dc8-cd9a4df11455.mov

The resulting code is:

import React, { useContext, useRef, useState, useEffect } from "react";
import { Editor } from "@tinymce/tinymce-react";
import { MathJaxBaseContext, MathJaxContext } from "better-react-mathjax";

const MathJaxEnabledEditor = () => {
  const mj = useRef(null);
  const mjContext = useContext(MathJaxBaseContext);
  const [content, setContent] = useState(`<p>Initial text!</p>`);

  useEffect(() => {
    mjContext.promise.then((mathJaxObject) => {
      mathJaxObject.startup.promise.then(() => {
        mj.current = mathJaxObject;
      });
    });
  }, []);
  const typeset = () => {
    setContent((previousContent) => {
      if (mj.current) {
        const container = document.createElement("div");
        container.innerHTML = previousContent;
        mj.current.typesetClear([container]);
        mj.current.typeset([container]);
        return container.innerHTML;
      } else return previousContent;
    });
  };

  return (
    <Editor
      value={content}
      onEditorChange={(event) => setContent(event)}
      init={{
        setup: (editor) => {
          editor.ui.registry.addButton("typeset", {
            text: "Typeset",
            onAction: typeset
          });
        },
        height: 500,
        menubar: false,
        plugins: [
          "advlist",
          "autolink",
          "lists",
          "link",
          "image",
          "charmap",
          "preview",
          "anchor",
          "searchreplace",
          "visualblocks",
          "code",
          "fullscreen",
          "insertdatetime",
          "media",
          "table",
          "code",
          "help",
          "wordcount"
        ],
        valid_elements: "*[*]",
        toolbar:
          "undo redo | typeset | blocks | " +
          "bold italic forecolor | alignleft aligncenter " +
          "alignright alignjustify | bullist numlist outdent indent | " +
          "removeformat | help",
        content_style:
          "body { font-family:Helvetica,Arial,sans-serif; font-size:14px }"
      }}
    />
  );
};

export default function App() {
  return (
    <MathJaxContext
      config={{
        tex: {
          inlineMath: [["$", "$"]]
        }
      }}
      src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"
    >
      <MathJaxEnabledEditor />
    </MathJaxContext>
  );
}

The strategy is to extract the MathJax object itself from better-react-mathjax and then do typesetting manually.

The following steps are noteworthy:

  1. Wrap the editor component in a MathJaxContext. We can then use useContext to extract MathJaxBaseContext in our editor component.
  2. Use a callback to add the MathJax object to a local ref upon loading.
  3. Use the TinyMCE editor as a controlled component and store its content in the wrapping component as a state.
  4. Create a button in the setup property of the editor's init property.
  5. Add the button to the toolbar in the init property of the editor.
  6. Make sure to allow all HTML entities in the input (valid_elements in the init property), otherwise the MathJax generated stuff will be removed by TinyMCE. You can either allow all elements or only those you want plus the elements that are added by MathJax. In the example, all elements are allowed.
  7. Due to a bug in MathJax (https://github.com/mathjax/MathJax/issues/2892), typesetting already typeset content results in redundant code being added. In this specific case, it will cause our editor to rerender infinitely due to changes in the content state. Avoid this by one of the way mentioned in the MathJax issue (here we explicitly import the tex-chtml.js source but you can also configure MathJax in the way described to avoid the problem).
  8. Since the function passed to the typeset button is passed only once initially, we use the trick of only involving the idempotent state setter when typesetting; should we try to access the state itself, it will be a stale closure from when the component was mounted and the typesetting will not work as intended.

Hope this solves your problems, it should work for Latex, AsciiMath and MathML just like normal.

Let me know how it goes :)

SoozaV commented 1 year ago

Thanks for your time and response @fast-reflexes, it looks really good (: I have a couple of questions:

  1. what if I need to use the '$' symbol in my editor's content? Is it possible to use another symbol/expression for inline/block math? Something like \( LaTeX_math )\. Because if I use something like: Cost: $10.°°. $ \fracc{10}{2}=5$ some other text the result is incorrect.
  2. Is it possible to edit a math formula after clicking on typeset button?

Thanks again dude. Regards!

fast-reflexes commented 1 year ago

You're welcome!

  1. Yes you can pick any delimiters in Latex and AsciiMath but not in MathML:

  2. No, that's not possible with the current setup since once you have typeset you have converted the input into a different format. In order to do what you ask for you would have to have two separate frames, one input frame and one output frame which shows the typeset (and stylized) result. There is not simple way to go from typeset content to MathJax input text either (afaik) so you can't really "untypeset" either.

    You could perhaps experiment with attaching an attribute with the original input to the outermost element resulting from MathJax typesetting and then create some on-click handler that would replace the created element with the input code again when the element is clicked on. Or simply implement an untypeset button yourself by giving each created MathJax output a unique identifier and then store a mapping between these ids and the MathJax input so that you can easily replace every created MathJax output with its original input text. Don't think it's undoable but it requires some fiddling on your end.
    The simplest way is to go for the two-frame solution with raw input text in one window and resulting output in the other.