jazzband / django-tinymce

TinyMCE integration for Django
http://django-tinymce.readthedocs.org/
MIT License
1.25k stars 317 forks source link

Using django-tinymce in inline editing mode #463

Open philgyford opened 2 months ago

philgyford commented 2 months ago

The django-tinymce documentation doesn't mention using it for inline editing mode. The only issue I've found about it (#135) was closed as "resolved" but with no info. Is it possible to use it for this purpose?

I feel like I've got close, but it requires some custom JavaScript, and it doesn't pick up any of the django-tinymce settings from settings.py.

I have a Django form:

class TestModelInlineUpdateForm(forms.ModelForm):
    class Meta:
        model = TestModel
        fields = ("description",)
        widgets = {"description": TinyMCE()}

And then this in my template:

  <form method="post" id="testForm">
    {% csrf_token %}
    {{ form.media }}

    <div class="tinymce" id="inline-editor">
      {{ object.description|safe }}
    </div>

    {% for hidden in form.hidden_fields %}
      {{ hidden }}
    {% endfor %}

    <input type="hidden" name="description" value="">

    <button type="submit">Update</button>
  </form>

  <script type="text/javascript">
    tinymce.init({
      selector: '#inline-editor',
      inline: true
    });

    // Set the description field to the new HTML on form submission.
    document.querySelector("#testForm").addEventListener("submit", function(ev) {
      ev.preventDefault();
      let descriptionHTML = document.querySelector("#inline-editor").innerHTML;
      let descriptionField = document.querySelector("input[name='description']");
      descriptionField.value = descriptionHTML;
      this.submit();
    });
  </script>

I have to manually copy the HTML from the editor to my manually-added hidden form field when the form is submitted. It works, but is clunky. And, as I say, django-tinymce's settings are ignored.

Have I missed an easier, better way?

philgyford commented 2 months ago

I should add, if there is a good way to do it, I'll happily do a PR to add the info to the docs!

philgyford commented 2 months ago

Now I've come back to this I realise that the only django-tinymce thing my form is using is the static files, in {{ form.media }}. Everything else is standard TinyMCE.

My aim is to be able to use the same config for both standard and inline TinyMCE editors, and to use django-filebrowser for uploading/browsing images in both kinds.

philgyford commented 2 months ago

I've made good progress. I've scrapped all the above and created my own widget, based off TinyMCE, to create and initialise a <div> as an inline TinyMCE element. Clicking it, it becomes an editable field, with my django-tinymce config, and the django-filebrowser upload button working.

My widgets.py:

import json
import tinymce
from django.core.serializers.json import DjangoJSONEncoder
from django.forms.utils import flatatt
from django.utils.safestring import mark_safe
from tinymce.widgets import TinyMCE

class TinyMCEInline(TinyMCE):
    def render(self, name, value, attrs=None, renderer=None):
        """
        A duplicate of TinyMCE.render() with one line added and one
        line changed.
        """
        if value is None:
            value = ""
        final_attrs = self.build_attrs(self.attrs, attrs)
        final_attrs["name"] = name
        if final_attrs.get("class", None) is None:
            final_attrs["class"] = "tinymce"
        else:
            final_attrs["class"] = " ".join(
                final_attrs["class"].split(" ") + ["tinymce"]
            )
        assert "id" in final_attrs, "TinyMCE widget attributes must contain 'id'"
        mce_config = self.get_mce_config(final_attrs)

        # NEW LINE #####################################################
        mce_config["inline"] = True

        mce_json = json.dumps(mce_config, cls=DjangoJSONEncoder)
        if tinymce.settings.USE_COMPRESSOR:
            compressor_config = {
                "plugins": mce_config.get("plugins", ""),
                "themes": mce_config.get("theme", "advanced"),
                "languages": mce_config.get("language", ""),
                "diskcache": True,
                "debug": False,
            }
            final_attrs["data-mce-gz-conf"] = json.dumps(compressor_config)
        final_attrs["data-mce-conf"] = mce_json

        # CHANGED LINE #################################################
        # CHANGED TEXTAREA TO DIV AND REMOVED escape()
        html = [f"<div{flatatt(final_attrs)}>{value}</div>"]

        return mark_safe("\n".join(html))

    def value_from_datadict(self, data, files, name):
        """
        TinyMCE submits the hidden field it generates using a name of
        "id_{name}". So we need to get the data using that, instead of
        our actual field name.
        """
        return data.get(f"id_{name}")

My form:

from django import forms
from .models import TestModel
from .widgets import TinyMCEInline

class TestModelInlineUpdateForm(forms.ModelForm):
    class Meta:
        model = TestModel
        fields = ("description",)
        widgets = {"description": TinyMCEInline()}

And the relevant part of my template:

  <form method="post">
    {% csrf_token %}
    {{ form.media }}
    {{ form }}

    <button type="submit">Update</button>
  </form>

I've only tried this with my one model, form and field, so there may well be cases in which it doesn't work, or could be better.

Having to duplicate the entire TinyMCE.render() method, only to add/change a couple of lines, isn't great. Some tweaks to the original widget could avoid all that duplication – if this approach seems good I'd be happy to try a PR, but any thoughts appreciated!

marius-mather commented 2 months ago

Based on your changes, it looks like you could just set "inline": True in your TinyMCE configuration (e.g. in settings.py), and then in TinyMCE.render() the HTML could be changed to:

if mce_config["inline"]:
    html = [f"<div{flatatt(final_attrs)}>{value}</div>"]
else:
    html = [f"<textarea{flatatt(final_attrs)}>{escape(value)}</textarea>"

Not escaping the HTML would have slightly different security implications though, so it might be good to document that if you're setting inline to True you should only use it for trusted sources of HTML.

philgyford commented 2 months ago

Based on your changes, it looks like you could just set "inline": True in your TinyMCE configuration (e.g. in settings.py)…

Yes, true. In my case this was a secondary use, as well as my default, but I could pass mce_attrs{"inline": True} in as an arg to TinyMCE().

Not escaping the HTML would have slightly different security implications though…

Yes, it's a shame about that but it seems necessary or else the HTML within the <div> is like &lt;p&gt;hello&lt;/p&gt;.

I can't see a way around needing the custom value_from_datadict(), which feels annoyingly fiddly. I guess it could be:

    def value_from_datadict(self, data, files, name):
        if name in data:
            return data.get(name)
        else:
            return data.get(f"id_{name}")

? Ideally there would be a way for that method to tell if mce_config["inline"] is True, and use that for the logic, but having had a quick look I'm not sure there is a way.