Making TinyMCE work with Rails, Turbolinks and Stimulus

djchadderton - May 14 '21 - - Dev Community

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.

. . . . . .