DEV Community

Cover image for Making TinyMCE work with Rails, Turbolinks and Stimulus
djchadderton
djchadderton

Posted on • Updated on

Making TinyMCE work with Rails, Turbolinks and Stimulus

TinyMCE is a great text editor as a drop-in replacement for textarea fields in forms, but it doesn't play nicely with Turbolinks, or anything else that doesn't do full page refresh. I've seen a lot of tips for forcing TinyMCE to unload itself through JavaScript before Turbolinks inserts the new content, but I could never get any of them to work. Even the tinymce-rails gem, which is supposed to have the fix built-in, never worked for me.

In the end, I resorted to using the tinymce-rails gem but inserting data-turbolinks-"false" into every link to a page with a form on it to force Turbolinks to do a full page refresh. Not an ideal solution.

Finally, I decided to try to crack how to use TinyMCE properly through Webpack on Rails 6 without any reconfiguring of Webpack itself. Different bits of the solution came from different places, so I've brought together here the method that worked for me.

First of all, in a Rails project with Turbolinks included and StimulusJS installed, install the TinyMCE package through Yarn.

yarn add tinymce
Enter fullscreen mode Exit fullscreen mode

In app/javascript/controllers, create the file tinymce_controller.js. Start with the usual blank Stimulus controller:

import { Controller } from 'stimulus'

export default class extends Controller {
}
Enter fullscreen mode Exit fullscreen mode

You'll need to import TinyMCE itself, plus icons, a theme and a skin from the node package. This will load the included defaults:

// Import TinyMCE
import tinymce from 'tinymce/tinymce'

// Import icons
import 'tinymce/icons/default/icons'

// Import theme
import 'tinymce/themes/silver/theme';

// Import skin
import 'tinymce/skins/ui/oxide/skin.min.css';
Enter fullscreen mode Exit fullscreen mode

You will also need to import each plugin that you intend to use, one at a time, for instance:

import 'tinymce/plugins/autoresize';
import 'tinymce/plugins/code';
import 'tinymce/plugins/fullscreen';
Enter fullscreen mode Exit fullscreen mode

Inside the export, set a target name for the textarea tag:

static targets = ['input']
Enter fullscreen mode Exit fullscreen mode

Set all of your default settings in an initializer method. Make sure you set content_css: false and skin: false as you have already imported both of these so you don't want TinyMCE to look for them in a separate file and give an error when it can't find them. For all other settings, see the TinyMCE main documentation. Here is my method.

initialize () {
  this.defaults = {
    content_css: false,
    skin: false,
    toolbar: [
      'styleselect | bold italic underline strikethrough superscript | blockquote numlist bullist link | alignleft aligncenter alignright | table',
      'undo redo | fullscreen preview code help'
            ],
    mobile: {
      toolbar: [
        'styleselect | bold italic underline strikethrough superscript',
        'blockquote numlist bullist link | alignleft aligncenter alignright | table',
        'undo redo | fullscreen preview code help'
      ]
    },
    plugins: 'link lists fullscreen help preview table code autoresize wordcount',
    menubar: false,
    style_formats: [
      { title: 'Heading 1', format: 'h1' },
      { title: 'Heading 2', format: 'h2' },
      { title: 'Heading 3', format: 'h3' },
      { title: 'Heading 4', format: 'h4' },
      { title: 'Heading 5', format: 'h5' },
      { title: 'Heading 6', format: 'h6' },
      { title: 'Paragraph', format: 'p'}
    ],
    max_height: 700,
    default_link_target: '_blank',
    link_title: false,
    autoresize_bottom_margin: 10,
    link_context_toolbar: true,
    relative_urls: false,
    browser_spellcheck: true,
    element_format: 'html',
    invalid_elements: ['span'],
    content_style: 'html { font-family: Roboto, sans-serif; line-height: 1.5; }'
  }
}
Enter fullscreen mode Exit fullscreen mode

The connect method initiates the app and applies the settings.

connect () {
  let config = Object.assign({ target: this.inputTarget }, this.defaults)
  tinymce.init(config)
}
Enter fullscreen mode Exit fullscreen mode

To make sure the editor loads properly on a page change or a failed submit rather than just showing a textarea, you must include a disconnect method to destroy the app instance.

disconnect () {
  tinymce.remove()
}
Enter fullscreen mode Exit fullscreen mode

In your header (for instance in your application.html.erb file), make sure you include pack tags for both the javascript and the css:

<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
<%= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
Enter fullscreen mode Exit fullscreen mode

(If you are using Turbo, the successor to Turbolinks, change data-turbolinks-track to data-turbo-track.)

On your form, you need to include the tinymce controller in the div surrounding your textarea and add the target name to the textarea itself, e.g.

<div class="field" data-controller="tinymce">
  <=% f.label :body %>
  <%= f.text_area :body, data: { tinymce_target: 'input' } %>
</div>
Enter fullscreen mode Exit fullscreen mode

And that should work. You can even include more than one text box on the same page and they should function independently without interfering with one another.

The full Stimulus controller code that I used with all of the plugins in the current standard package (v5.8.0) to be uncommented if required is below.

import { Controller } from 'stimulus'

// Import TinyMCE
import tinymce from 'tinymce/tinymce'

// Import icons
import 'tinymce/icons/default/icons'

// Import theme
import 'tinymce/themes/silver/theme';

// Import skin
import 'tinymce/skins/ui/oxide/skin.min.css';

// Import plugins

// import 'tinymce/plugins/advlist';
// import 'tinymce/plugins/anchor';
// import 'tinymce/plugins/autolink';
import 'tinymce/plugins/autoresize';
// import 'tinymce/plugins/autosave';
// import 'tinymce/plugins/bbcode';
// import 'tinymce/plugins/charmap';
import 'tinymce/plugins/code';
// import 'tinymce/plugins/codesample';
// import 'tinymce/plugins/colorpicker';
// import 'tinymce/plugins/contextmenu';
// import 'tinymce/plugins/directionality';
// import 'tinymce/plugins/emoticons';
// import 'tinymce/plugins/fullpage';
import 'tinymce/plugins/fullscreen';
import 'tinymce/plugins/help';
// import 'tinymce/plugins/hr';
// import 'tinymce/plugins/image';
// import 'tinymce/plugins/imagetools';
// import 'tinymce/plugins/insertdatetime';
// import 'tinymce/plugins/legacyoutput';
import 'tinymce/plugins/link';
import 'tinymce/plugins/lists';
// import 'tinymce/plugins/media';
// import 'tinymce/plugins/nonbreaking';
// import 'tinymce/plugins/noneditable';
// import 'tinymce/plugins/pagebreak';
// import 'tinymce/plugins/paste';
import 'tinymce/plugins/preview';
// import 'tinymce/plugins/print';
// import 'tinymce/plugins/quickbars';
// import 'tinymce/plugins/save';
// import 'tinymce/plugins/searchreplace';
// import 'tinymce/plugins/spellchecker';
// import 'tinymce/plugins/tabfocus';
import 'tinymce/plugins/table';
// import 'tinymce/plugins/template';
// import 'tinymce/plugins/textcolor';
// import 'tinymce/plugins/textpattern';
// import 'tinymce/plugins/toc';
// import 'tinymce/plugins/visualblocks';
// import 'tinymce/plugins/visualchars';
import 'tinymce/plugins/wordcount';

export default class extends Controller {
  static targets = ['input']

  initialize () {
    this.defaults = {
      content_css: false,
      skin: false,
      toolbar: [
        'styleselect | bold italic underline strikethrough superscript | blockquote numlist bullist link | alignleft aligncenter alignright | table',
        'undo redo | fullscreen preview code help'
              ],
      mobile: {
        toolbar: [
          'styleselect | bold italic underline strikethrough superscript',
          'blockquote numlist bullist link | alignleft aligncenter alignright | table',
          'undo redo | fullscreen preview code help'
        ]
      },
      plugins: 'link lists fullscreen help preview table code autoresize wordcount',
      menubar: false,
      style_formats: [
        { title: 'Heading 1', format: 'h1' },
        { title: 'Heading 2', format: 'h2' },
        { title: 'Heading 3', format: 'h3' },
        { title: 'Heading 4', format: 'h4' },
        { title: 'Heading 5', format: 'h5' },
        { title: 'Heading 6', format: 'h6' },
        { title: 'Paragraph', format: 'p'}
      ],
      max_height: 700,
      default_link_target: '_blank',
      link_title: false,
      autoresize_bottom_margin: 10,
      link_context_toolbar: true,
      relative_urls: false,
      browser_spellcheck: true,
      element_format: 'html',
      invalid_elements: ['span'],
      content_style: 'html { font-family: Roboto, sans-serif; line-height: 1.5; }'
    }
  }

  connect () {
    let config = Object.assign({ target: this.inputTarget }, this.defaults)
    tinymce.init(config)
  }

  disconnect () {
    tinymce.remove()
  }
}
Enter fullscreen mode Exit fullscreen mode

NB This article was written to work with TinyMCE 5. The upgrade to v6 broke a few things; see my follow-up article for how I managed to fix it.

Top comments (4)

Collapse
 
leastbad profile image
leastbad

Interesting!

Are all of the configurations that you've hard-coded actually necessary? It seems like most of them are likely TinyMCE defaults already, no?

Either way, perhaps it would make sense to expose the options via the Stimulus values API so that people could set their own options via data attributes?

One thing you most likely should consider adding if you're targeting Turbolinks is a check to make sure that Turbolinks is not in cache preview mode. Just add a getter, like so:

  get preview () {
    return (
      document.documentElement.hasAttribute('data-turbolinks-preview') ||
      document.documentElement.hasAttribute('data-turbo-preview')
    )
  }
Enter fullscreen mode Exit fullscreen mode

And then wrap your init and remove methods in a gate, eg:

disconnect () {
  if (!this.preview) tinymce.remove()
}
Enter fullscreen mode Exit fullscreen mode

Just a thought! If you do take these ideas forward, please do consider cutting an npm package and releasing it. I'd definitely list it on StimulusConnect.

Collapse
 
djchadderton profile image
djchadderton

Thanks for the suggestions, which I'll certainly try out.

The configuration is just a suggestion, showing the settings I've been using for a long time for all text boxes right across the site, so it's fine in my case to hardcode them, but exposing the settings would be a useful addition to make it more universally useful, I agree.

As far as I remember, all of the settings I've changed are different from the defaults, but I could be wrong.

Collapse
 
tioneb12 profile image
Tioneb12

I followed your post step by step, but get an error :

Started GET "/packs/js/models/dom/model.js" for ::1 at 2023-02-06 12:21:42 +0100

ActionController::RoutingError (No route matches [GET] "/packs/js/models/dom/model.js")
Enter fullscreen mode Exit fullscreen mode

And in the js console :

Invalid value passed for the invalid_elements option. The value must be a string

const create$5 = (editor, initialOptions) => {
      const registry = {};
      const values = {};
      const setValue = (name, value, processor) => {
        const result = processValue(value, processor);
        if (isValidResult(result)) {
          values[name] = result.value;
          return true;
        } else {
          console.warn(getErrorMessage(`Invalid value passed for the ${ name } option`, result));
          return false;
        }
      };
Enter fullscreen mode Exit fullscreen mode
Collapse
 
djchadderton profile image
djchadderton

Which version of TinyMCE are you using? This was written for v5, and I've had to rewrite a few things to make v6 work. I should probably write a new piece.