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
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 {
}
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';
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';
Inside the export, set a target name for the textarea tag:
static targets = ['input']
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; }'
}
}
The connect
method initiates the app and applies the settings.
connect () {
let config = Object.assign({ target: this.inputTarget }, this.defaults)
tinymce.init(config)
}
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()
}
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' %>
(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>
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()
}
}
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)
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:
And then wrap your
init
andremove
methods in a gate, eg: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.
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.
I followed your post step by step, but get an error :
And in the js console :
Invalid value passed for the invalid_elements option. The value must be a string
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.