Creating a schematic editor within Wagtail CMS with StimulusJS

lb

LB (Ben Johnston)

Posted on February 20, 2022

Creating a schematic editor within Wagtail CMS with StimulusJS

Goal

  • Our goal is to create a way to present a product (or anything) visually alongside points over the image that aligns to a description.
  • Often content like this has to be rendered fully as an image, see the Instructables espresso machine article as an example.
  • However, we want to provide a way to have the image and its labels in separate content, this means the content is more accessible, links can be provided to sub-content and the labels can be translated if needed. See the website for the Aremde Nexus Prop coffee machine as an example. Not only is this coffee machine amazing, made in Brisbane, Australia but their website has some nice pulsating 'dots' that can be hovered to show features of the machine.

Aremde Coffee Machine website - example of our goal

Our approach

A note on naming - Schematic - this can mean a few different things and maybe diagram would be more appropriate but we will go with schematic to mean the image along with some points with labels and point for the individual points that overlay the image.

  1. Create a new Django app to contain the schematic model, we will design the model to contain the image and 'points' that align with the image.
  2. Create a new Page that can add the Schematic and use Wagtail's built-in InlinePanel to allow for basic editing of these points.
  3. Get the points and image showing in the page's template.
  4. Refine the Wagtail CMS editing interface to firstly show the points visually over the image and then allow drag & drop positioning of the points all within the editor.

Versions

Assumptions

  • You have a working Wagtail project running locally, either your own project or something like the bakerydemo project.
  • You are using the images and snippets Wagtail apps (common in most installations).
  • You have installed the Wagtail API and have set up the URLs as per the basic configuration.
  • You have a basic knowledge of Wagtail, Django, Python and JavaScript.

Tutorial

Part 1 - Create a new schematics app plus Schematic & SchematicPoint models

  1. python manage.py startapp schematics - create a new Django application to house the models and assets.
  2. Add 'schematics' to your INSTALLED_APPS within your Django settings.
  3. Create a Wagtail snippet which will hold our Schematic and SchematicPoint models, code and explanation below.
  4. Run ./manage.py makemigrations, check the output matches expectations and then ./manage.py migrate to migrate your local DB.
  5. Restart your dev server ./manage.py runserver 0.0.0.0:8000 and validate that the new model is now available within the Snippets section accessible from the sidebar menu.
  6. Now create a single Schematic snippet so that there is some test data to work with and so you get a feel for the editing of this content.

Wagtail CMS Snippet editor setup

Code - models.py

  • We will create two models, Schematic and SchematicPoint, the first will be a Wagtail snippet using the @register_snippet decorator via from wagtail.snippets.models import register_snippet.
  • The Schematic model has two fields title (a simple CharField) and image (a Wagtail image), the panels will also reference the related points model.
  • The SchematicPoint model has a ParentalKey (from modelcluster) which is included with Wagtail, for more information about this read the InlinePanel & modelclusters section of the Wagtail docs.
  • The SchematicPoint also has an x and y coordinate (percentages), the reasoning of using percentages is that it maps well to scenarios where the image may change or image may be shown at various sizes, if we go to px we have to solve a whole bunch of problems that present themselves. We also use the DecimalField to allow for up to 2 decimal places of precision within the value, e.g. 0.01 through to 99.99. (We are using max digits 5 because technically 100.00 is valid).
  • Note that we are using MaxValueValidator/MinValueValidator for the server-side validation of the values and NumberInput widget attrs for the client side (browser) validation. Django widget attrs is a powerful way to add HTML attributes to the form fields without needing to dig into templates, we will use this more later.
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models

from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel

from wagtail.admin.edit_handlers import (
    FieldPanel,
    FieldRowPanel,
    InlinePanel,
)
from wagtail.core.models import Orderable
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.search import index
from wagtail.snippets.models import register_snippet


@register_snippet
class Schematic(index.Indexed, ClusterableModel):
    title = models.CharField("Title", max_length=254)

    image = models.ForeignKey(
        "wagtailimages.Image",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )

    panels = [
        FieldPanel("title"),
        ImageChooserPanel("image"),
        InlinePanel("points", heading="Points", label="Point"),
    ]

    def __str__(self):
        title = getattr(self, "title", "Schematic")
        return f"Schematic - {title} ({self.pk})"

    class Meta:
        verbose_name_plural = "Schematics"
        verbose_name = "Schematic"


class SchematicPoint(Orderable, models.Model):
    schematic = ParentalKey(
        "schematics.Schematic",
        on_delete=models.CASCADE,
        related_name="points",
    )

    label = models.CharField("Label", max_length=254)

    x = models.DecimalField(
        verbose_name="X →",
        max_digits=5,
        decimal_places=2,
        default=0.0,
        validators=[MaxValueValidator(100.0), MinValueValidator(0.0)],
    )

    y = models.DecimalField(
        verbose_name="Y ↑",
        max_digits=5,
        decimal_places=2,
        default=0.0,
        validators=[MaxValueValidator(100.0), MinValueValidator(0)],
    )

    panels = [
        FieldPanel("label"),
        FieldRowPanel(
            [
                FieldPanel(
                    "x", widget=forms.NumberInput(attrs={"min": 0.0, "max": 100.0})
                ),
                FieldPanel(
                    "y", widget=forms.NumberInput(attrs={"min": 0.0, "max": 100.0})
                ),
            ]
        ),
    ]

    def __str__(self):
        schematic_title = getattr(self.schematic, "title", "Schematic")
        return f"{schematic_title} - {self.label}"

    class Meta:
        verbose_name_plural = "Points"
        verbose_name = "Point"

Enter fullscreen mode Exit fullscreen mode

Part 2 - Create a new ProductPage model that will use the schematic model

  1. You may want to integrate this into an existing page but for the sake of the tutorial, we will create a simple ProductPage that will have a ForeignKey to our Schematic snippet.
  2. The snippet will be selectable via the SnippetChooserPanel which provides a chooser modal where the snippet can be selected. This also allows the same schematic to be available across multiple instances of the ProductPage or even available in other pages and shared as a discrete bit of content.
  3. Remember to run ./manage.py makemigrations, check the output matches expectations and then ./manage.py migrate to migrate your local DB.
  4. Finally, be sure to create a new ProductPage in the Wagtail admin and link its schematic to the one created in step 1 to test the snippet chooser is working.

Page model editing with Snippet Chooser

Code - models.py

from django.db import models

from wagtail.core.models import Page
from wagtail.snippets.edit_handlers import SnippetChooserPanel


class ProductPage(Page):

    schematic = models.ForeignKey(
        "schematics.Schematic",
        null=True,
        on_delete=models.SET_NULL,
        related_name="product_page_schematic",
    )

    content_panels = Page.content_panels + [SnippetChooserPanel("schematic")]

Enter fullscreen mode Exit fullscreen mode

Part 3 - Output the points over an image in the Page's template

  1. Now create a template to output the image along with the points, this is a basic template that gets the general idea across of using the point coordinates to position them over the image.
  2. We will use the wagtailimages_tags to allow the rendering of an image at a specific size and the usage of the self.schematic within the template to get the points data.

Published page with the points showing over the image

Code - myapp/templates/schematics/product_page.html

  • The template below is built on the bakerydemo, so there is a base template that is extended.
  • Please note the CSS is not polished and will need to be adjusted to suit your own branding and desired hover behaviour.
{% extends "base.html" %}
{% load wagtailimages_tags %}

{% block head-extra %}
  <style>
    .schematic {
      position: relative;
    }

    .schematic .points {
      margin-bottom: 0;
    }

    .schematic .point {
      position: absolute;
    }

    .schematic .point::before {
      background-color: #fb7575;
      border-radius: 50%;
      box-shadow: 0 -2px 0 rgba(0, 0, 0, 0.1) inset;
      content: "";
      display: block;
      border: 0.5rem solid transparent;
      height: 2.75rem;
      background-clip: padding-box; /* ensures the 'hover' target is larger than the visible circle */
      position: absolute;
      transform: translate(-50%, -50%);
      width: 2.75rem;
      z-index: 1;
    }

    .point .label {
      opacity: 0; /* hide by default */
      position: absolute;

      /* vertically center */
      top: 50%;
      transform: translateY(-50%);

      /* move to right */
      left: 100%;
      margin-left: 1.25rem; /* and add a small left margin */

      /* basic styles */
      font-family: sans-serif;
      width: 12rem;
      padding: 5px;
      border-radius: 5px;
      background: #000;
      color: #fff;
      text-align: center;
      transition: opacity 300ms ease-in-out;
      z-index: 10;
    }

    .schematic .point:hover .label {
      opacity: 1;
    }
  </style>
{% endblock head-extra %}

{% block content %}
  {% include "base/include/header.html" %}
  <div class="container">
    <div class="row">
      {% image self.schematic.image width-1920 as schematic_image %}
      <div class="schematic col-md-12">
        <img src="{{ schematic_image.url }}" alt="{{ schematic.title }}" />
        <ul class="points">
          {% for point in self.schematic.points.all %}
          <li class="point" style="left: {{ point.x }}%; bottom: {{ point.y }}%">
            <span class="label">{{ point.label }}</span>
          </li>
          {% endfor %}
        </ul>
      </div>
    </div>
  </div>
{% endblock content %}
Enter fullscreen mode Exit fullscreen mode

Part 4 - Enhance the editor's experience to show a different image size

  • Before we can try to show the 'points' within the image in the editor we need to change the behaviour of the built-in ImageChooserPanel to load a larger image when editing. This panel has two modes, editing an existing 'saved' value (shows the image on load) or updating an image by choosing a new one either for the first time or editing, this image is provided from the server.
  • At this point we will start writing some JavaScript and use the Stimulus 'modest' framework, see the bottom of this article for a bit of a high-level overview of Stimulus if you have not yet heard about it. Essentially, Stimulus gives us a way to assign data- attributes to elements to link their behaviour to a Controller class in JavaScript and avoids a lot of the boilerplate usually needed when working with jQuery or vanilla (no framework) JS such as adding event listeners or targeting elements predictably.
  • On the server-side we will create a sub-class of ImageChooserPanel which allows us to modify the size of the image that is returned if already saved and add our template overrides so we can update the HTML.
  • We will break this part into a few sub-steps.

Part 4a - Adding Stimulus via wagtail_hooks

  • Wagtail provides a system of 'hooks' where you can add a file wagtail_hooks.py to your app and it will be run by Wagtail on load.
  • We will use the insert_editor_js hook to add our JavaScript module.
  • The JavaScript used from here on in assumes you are supporting browsers that have ES6 support and relies extensively on ES6 modules, arrow functions and classes.
  • We will be installing Stimulus as an ES6 module in a similar way to the Stimulus installation guide - without using a build system.

Create a new file schematics/wagtail_hooks.py

  • Once created, stop your Django dev server and restart it (hooks will not run the first time after the file is added unless you restart).
  • You can validate this step is working by checking the browser inspector - checking that the script module exists, remember this will only show on editing pages or editing models and not on the dashboard for example due to the Wagtail hook used.
  • Assuming you are running Django with DEBUG = True in your dev server settings you should also see some console info about the status of Stimulus.
from django.conf import settings
from django.utils.html import format_html

from wagtail.core import hooks


@hooks.register("insert_editor_js")
def insert_stimulus_js():
    return format_html(
        """
        <script type="module">
            import {{ Application, Controller }} from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
            const Stimulus = Application.start();
            {}
            window.dispatchEvent(new CustomEvent('stimulus:init', {{ detail: {{ Stimulus, Controller }} }}));
        </script>
        """,
        # set Stimulus to debug mode if running Django in DEBUG mode
        "Stimulus.debug = true;" if settings.DEBUG else "",
    )

Enter fullscreen mode Exit fullscreen mode

Part 4b - Creating schematics/edit_handlers.py with a custom ImageChooserPanel

  1. Create a new file schematics/edit_handlers.py.
  2. In this file we will sub-class the built-in ImageChooserPanel and its usage of AdminImageChooser to customise the behaviour via a new class SchematicImageChooserPanel.
  3. SchematicImageChooserPanel extends ImageChooserPanel and does two things; it updates the widget_overrides to use a second custom class AdminPreviewImageChooser and passes down a special data attribute to the input field. This attribute is a Stimulus target attribute and allows our JavaScript to easily access this field.
  4. Within AdminPreviewImageChooser we override the get_value_data method to customise the image preview output, remember that this is only used when editing an existing model with a chosen image. We are using the get_rendition method built-in to Wagtail's Image model.
  5. We also need to ensure we use the SchematicImageChooserPanel in our models.py.
  6. Remember to validate before moving on, you can do this by checking the image that is loaded when editing a model that already has a chosen image, it should be a much higher resolution version.
# schematics/edit_handlers.py
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.images.widgets import AdminImageChooser


class AdminPreviewImageChooser(AdminImageChooser):
    """
    Generates a larger version of the AdminImageChooser
    Currently limited to showing the large image on load only.
    """

    def get_value_data(self, value):
        value_data = super().get_value_data(value)

        if value_data:
            image = self.image_model.objects.get(pk=value_data["id"])
            # note: the image string here should match what is used in the template
            preview_image = image.get_rendition("width-1920")
            value_data["preview"] = {
                "width": preview_image.width,
                "height": preview_image.height,
                "url": preview_image.url,
            }

        return value_data


class SchematicImageChooserPanel(ImageChooserPanel):
    def widget_overrides(self):
        return {
            self.field_name: AdminPreviewImageChooser(
                attrs={
                    "data-schematic-edit-handler-target": "imageInput",
                }
            )
        }

Enter fullscreen mode Exit fullscreen mode
# schematics/models.py

# ... existing imports

from .edit_handlers import SchematicImageChooserPanel


@register_snippet
class Schematic(index.Indexed, ClusterableModel):

    # ...fields

    panels = [
        FieldPanel("title"),
        SchematicImageChooserPanel("image"), # ImageChooserPanel("image") - removed
        InlinePanel("points", heading="Points", label="Point"),
    ]


# .. other model - SchematicPoint

Enter fullscreen mode Exit fullscreen mode

Part 4c - Adding a custom EditHandler

  • In Wagtail, there is a core class EditHandler which contains much of the rendering of lists of containers/fields within a page and other editing interfaces (including snippets).
  • So that we can get more control over how our Schematic editor is presented, we will need to create a sub-class of this called SchematicEditHandler.
  • Our SchematicEditHandler will add some HTML around the built-in class and also provide the editor specific JS/CSS we need for this content. We could add the CSS/JS via more Wagtail Hooks but then it would load on every single editor page, even if the user is not editing the Schemas.

In the file schematics/edit_handlers.py create a custom SchematicEditHandler

  • This new file (schematics/edit_handlers.py) will contain our custom editor handler classes, we will start with SchematicEditHandler which extends ObjectList.
  • Using the get_form_class method we generate a new dynamic class with the type function that has a Media class within it.
  • Django will use the Media class on a Form to load any JS or CSS files declared but only once and only if the form is shown.
# schematics/edit_handlers.py
from django.utils.html import format_html # this import is added

from wagtail.admin.edit_handlers import ObjectList # this import is added
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.images.widgets import AdminImageChooser

# ... other classes

class SchematicEditHandler(ObjectList):
    template = "schematics/edit_handlers/schematic_edit_handler.html"

    def get_form_class(self):
        form_class = super().get_form_class()
        return type(
            form_class.__name__,
            (form_class,),
            {"Media": self.Media},
        )

    class Media:
        css = {"all": ("css/schematic-edit-handler.css",)}
        js = ("js/schematic-edit-handler.js",)

Enter fullscreen mode Exit fullscreen mode

Use the SchematicEditHandler on the Schematic model

  • We will need to ensure we use this SchematicEditHandler in our models.py
  • Once this is done, you can validate that it is working by reloading the Wagtail admin, editing an existing Schematic snippet and checking the network tools in the browser inspector. It should have tried to load the schematic-edit-handler.css & schematic-edit-handler.js files - which are not yet added - just check that the requests were made.
# schematics/models.py

# ... existing imports

from .edit_handlers import (
    SchematicEditHandler,
    SchematicImageChooserPanel,
)


@register_snippet
class Schematic(index.Indexed, ClusterableModel):

    # ...fields

    # panels = [ ... put the edit_handler after panels

    edit_handler = SchematicEditHandler(panels)

# .. other model - SchematicPoint

Enter fullscreen mode Exit fullscreen mode

Part 4d - Adding initial JS & CSS for the schematic edit handler

Create schematic-edit-handler.js - Stimulus Controller

  • This file will be a Stimulus Controller that gets created once the event stimulus:init fires on the window (added earlier by our wagtail_hooks.py).
  • static targets = [... - this tells the controller to look at for a DOM element and 'watch' it to check if it exists or gets created while the controller is active. This will specifically look for the data attribute data-schematic-handler-target="imageInput" and make it available inside the Controller's instance.
  • connect is a class method similar to componentDidMount in React or x-init/init() in Alpine.js - it essentially means that there is a DOM element available.
  • Once connected, we call a method setupImageInputObserver which we have made in this class, it uses the MutationObserver browser API to listen to the image's input value. The reason we cannot just use the 'change' event is due to this value being updated programmatically, we also cannot easily listen to when the chooser modal closes as those are jQuery events that are not compatible with built-in browser events.
  • Finally, once we know the image input (id) has changed and has a value (e.g. was not just cleared), we can fire of an API call to the internal Wagtail API to get the image path, this happens in the updateImage method. Once resolved, we update the src on the img tag.
  • You can now validate this by refreshing and then changing an image to a new one via the image chooser, the newly loaded image should get updated to the full size variant of that image.
// static/js/schematic-edit-handler.js
window.addEventListener("stimulus:init", ({ detail }) => {
  const Stimulus = detail.Stimulus;
  const Controller = detail.Controller;

  class SchematicEditHandler extends Controller {
    static targets = ["imageInput"];

    connect() {
      this.setupImageInputObserver();
    }

    /**
     * Once connected, use DOMMutationObserver to 'listen' to the image chooser's input.
     * We are unable to use 'change' event as it is updated by JS programmatically
     * and we cannot easily listen to the Bootstrap modal close as it uses jQuery events.
     */
    setupImageInputObserver() {
      const imageInput = this.imageInputTarget;

      const observer = new MutationObserver((mutations) => {
        const { oldValue = "" } = mutations[0] || {};
        const newValue = imageInput.value;
        if (newValue && oldValue !== newValue)
          this.updateImage(newValue, oldValue);
      });

      observer.observe(imageInput, {
        attributeFilter: ["value"],
        attributeOldValue: true,
        attributes: true,
      });
    }

    /**
     * Once we know the image has changed to a new one (not just cleared)
     * we use the Wagtail API to find the original image URL so that a more
     * accurate preview image can be updated.
     *
     * @param {String} newValue
     */
    updateImage(newValue) {
      const image = this.imageInputTarget
        .closest(".field-content")
        .querySelector(".preview-image img");

      fetch(`/api/v2/images/${newValue}/`)
        .then((response) => {
          if (response.ok) return response.json();
          throw new Error(`HTTP error! Status: ${response.status}`);
        })
        .then(({ meta }) => {
          image.setAttribute("src", meta.download_url);
        })
        .catch((e) => {
          throw e;
        });
    }
  }

  // register the above controller
  Stimulus.register("schematic-edit-handler", SchematicEditHandler);
});
Enter fullscreen mode Exit fullscreen mode

Create static/css/schematic-edit-handler.css styles

  • This is a base starting point to get the preview image and the action buttons to stack instead of show inline, plus allow the image to get larger based on the actual image used.
/* static/css/schematic-edit-handler.css */
/* preview image - container */

.schematic-edit-handler .image-chooser .chosen {
  padding-left: 0;
}

.schematic-edit-handler .image-chooser .preview-image {
  display: inline-block; /* ensure container matches image size */
  max-width: 100%;
  margin: 2rem 0;
  float: none;
  position: relative;
}

.schematic-edit-handler .image-chooser .preview-image img {
  max-height: 100%;
  max-width: 100%;
}
Enter fullscreen mode Exit fullscreen mode

Larger preview image used with the custom ImageChooser panel

Part 5 - Enhance the editor's experience to show point positioning

  • In this next part, our goal is to have the points shown visually over the image.
  • The styling here is very similar to the styling used in our page template but we need to ensure that the points move when the inputs change.
  • We will continue to expand on our Stimulus controller to house the JS behaviour and leverage another data- attribute around the InlinePanel used.
  • Working with the InlinePanel (also called expanding formset) has some nuance, the main thing to remember is that these panels can be deleted but this deletion only happens visually as there are input fields under the hood that get updated. Also, the panels can be reordered and added at will.

5a - Add a SchematicPointPanel that will use a new template schematics/edit_handlers/schematic_point_panel.html

  • We will update schematics/edit_handlers.py with another custom panel, this time extending the MultiFieldPanel, which is essentially just a thin wrapper around a bunch of fields.
  • This custom class does one thing, point the panel to a new template.
# schematics/edit_handlers.py
from django.utils.html import format_html

from wagtail.admin.edit_handlers import MultiFieldPanel, ObjectList # update - added MultiFieldPanel
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.images.widgets import AdminImageChooser

# ... other classes

class SchematicPointPanel(MultiFieldPanel):
    template = "schematics/edit_handlers/schematic_point_panel.html"

Enter fullscreen mode Exit fullscreen mode
  • Create the new template schematics/edit_handlers/schematic_point_panel.html and all it does is wrap the existing multi_field_panel in a div that will add a class and add another Stimulus target.
<div class="schematic-point-panel" data-schematic-edit-handler-target="point">
  {% extends "wagtailadmin/edit_handlers/multi_field_panel.html" %}
</div>
Enter fullscreen mode Exit fullscreen mode

5b - Use the SchematicPointPanel in models.py & update attrs

  • Now that we have created SchematicPointPanel we can use it inside our SchematicPoint model to wrap the fields.
  • We have also reworked the various FieldPanel items to leverage the widget attribute so we can add some more data-attributes.
  • Note that the data-action is a specific Stimulus attribute that says 'when this input changes fire a method on the Controller. It can be used to add specific event listeners as we will see later but the default behaviour on input elements is the 'change' event.
  • We also add some data-point- attributes, these are not Stimulus specific items but just a convenience attribute to find those elements in our Stimulus controller, we could use more target type attributes but that is not critical for the scope of this tutorial.
  • A reminder that Django will smartly handle some attributes and when Python True is passed, it will be converted to a string 'true' in HTML - thanks Django!
# schematics/models.py
# ... imports

from .edit_handlers import (
    SchematicEditHandler,
    SchematicImageChooserPanel,
    SchematicPointPanel, # added
)

# Schematic model

class SchematicPoint(Orderable, models.Model):
    # schematic/label fields

    x = models.DecimalField(
        verbose_name="X →",
        max_digits=5,
        decimal_places=2,
        default=0.0,
        validators=[MaxValueValidator(100.0), MinValueValidator(0.0)],
    )

    y = models.DecimalField(
        verbose_name="Y ↑",
        max_digits=5,
        decimal_places=2,
        default=0.0,
        validators=[MaxValueValidator(100.0), MinValueValidator(0)],
    )

    fields = [
        FieldPanel(
            "label",
            widget=forms.TextInput(
                attrs={
                    "data-action": "schematic-edit-handler#updatePoints",
                    "data-point-label": True,
                }
            ),
        ),
        FieldRowPanel(
            [
                FieldPanel(
                    "x",
                    widget=forms.NumberInput(
                        attrs={
                            "data-action": "schematic-edit-handler#updatePoints",
                            "data-point-x": True,
                            "min": 0.0,
                            "max": 100.0,
                        }
                    ),
                ),
                FieldPanel(
                    "y",
                    widget=forms.NumberInput(
                        attrs={
                            "data-action": "schematic-edit-handler#updatePoints",
                            "data-point-y": True,
                            "min": 0.0,
                            "max": 100.0,
                        }
                    ),
                ),
            ]
        ),
    ]

    panels = [SchematicPointPanel(fields)]

    # ... def/Meta

# other classes
Enter fullscreen mode Exit fullscreen mode

5c - Add a template to templates/schematics/edit_handlers/schematic_edit_handler.html

  • We need a way to determine how to output a point in the editor UI, and while we can build this up as a string in the Stimulus controller, let's make our lives easier to and use a HTML template element.
  • This template will be pre-loaded with the relevant data attributes we need and a label slot to add the label the user has entered. The nice thing about this approach is that we can modify this rendering just by changing the HTML template later.
<!-- templates/schematics/edit_handlers/schematic_edit_handler.html -->
<div class="schematic-edit-handler" data-controller="schematic-edit-handler">
  <template data-schematic-edit-handler-target="imagePointTemplate">
    <li
      class="point"
      data-schematic-edit-handler-target="imagePoint"
    >
      <span class="label"></span>
    </li>
  </template>
  {% extends "wagtailadmin/edit_handlers/object_list.html" %}
</div>
Enter fullscreen mode Exit fullscreen mode

5d - Update the SchematicEditHandler Stimulus controller to output points

  • In our Stimulus Controller we will add 4 new targets; imagePoint - shows the point visually over the preview images, imagePoints - container for the imagePoint elements, imagePointTemplate - the template to use, set in the above step, point - each related model added via the InlinePanel children.
  • Now we can add a pointTargetConnected method, this is a powerful built-in part of the Stimulus controller where each target gets its own connected/disconnected callbacks. These also fire when initially connected so we can have a consistent way to know what InlinePanel children exist on load AND any that are added by the user later without having to do too much of our own code here.
  • pointTargetConnected basically adds a 'delete' button listener so we know when to re-update our points.
  • updatePoints does the bulk of the heavy lifting here, best to read through the code line by line to understand it. Essentially it goes through each of the point targeted elements and builds up an array of elements based on the imagePointTemplate but only if that panel is not marked as deleted. It then puts those points into a ul element next to the preview image, which itself has a target of imagePoints to be deleted and re-written whenever we need to run another update.
  • You should be able to validate this by reloading the page and seeing that there are a bunch of new elements added just under the image.
// static/js/schematic-edit-handler.js

class SchematicEditHandler extends Controller {
    static targets = [
      "imageInput",
      "imagePoint",
      "imagePoints",
      "imagePointTemplate",
      "point",
    ];

    connect() {
      this.setupImageInputObserver();
      this.updatePoints(); // added
    }

    /**
     * Once a new point target (for each point within the inline panel) is connected
     * add an event listener to the delete button so we know when to re-update the points.
     *
     * @param {HTMLElement} element
     */
    pointTargetConnected(element) {
      const deletePointButton = element
        .closest("[data-inline-panel-child]")
        .querySelector('[id*="DELETE-button"]');

      deletePointButton.addEventListener("click", (event) => {
        this.updatePoints(event);
      });
    }

    // setupImageInputObserver() ...
    // updateImage() ...

    /**
     * Removes the existing points shown and builds up a new list,
     * ensuring we do not add a point visually for any inline panel
     * items that have been deleted.
     */
    updatePoints() {
      if (this.hasImagePointsTarget) this.imagePointsTarget.remove();

      const template = this.imagePointTemplateTarget.content.firstElementChild;

      const points = this.pointTargets
        .reduce((points, element) => {
          const inlinePanel = element.closest("[data-inline-panel-child]");
          const isDeleted = inlinePanel.matches(".deleted");

          if (isDeleted) return points;

          return points.concat({
            id: inlinePanel.querySelector("[id$='-id']").id,
            label: element.querySelector("[data-point-label]").value,
            x: Number(element.querySelector("[data-point-x]").value),
            y: Number(element.querySelector("[data-point-y]").value),
          });
        }, [])
        .map(({ id, x, y, label }) => {
          const point = template.cloneNode(true);
          point.dataset.id = id;
          point.querySelector(".label").innerText = label;
          point.style.bottom = `${y}%`;
          point.style.left = `${x}%`;
          return point;
        });

      const newPoints = document.createElement("ol");
      newPoints.classList.add("points");
      newPoints.dataset.schematicEditHandlerTarget = "imagePoints";

      points.forEach((point) => {
        newPoints.appendChild(point);
      });

      this.imageInputTarget
        .closest(".field-content")
        .querySelector(".preview-image")
        .appendChild(newPoints);
    }
//   rest of controller definition & registration
Enter fullscreen mode Exit fullscreen mode

5e - Add styles for the points in schematic-edit-handler.css

  • There is a fair bit of CSS happening here but our goal is to ensure that the points show correctly over the image and can be positioned absolutely.
  • We also add a few nice visuals such as a label on hover, a number that shows in the circle and a number against each inline panel so that our users can mentally map these things easier.
/* static/css/schematic-edit-handler.css */

/* preview image - container ...(keep as is) */

/* inline panels - add visible numbers */

.schematic-edit-handler .multiple {
  counter-reset: css-counter 0;
}

.schematic-edit-handler [data-inline-panel-child]:not(.deleted) {
  counter-increment: css-counter 1;
}

.schematic-edit-handler
  [data-inline-panel-child]:not(.deleted)
  > fieldset::before {
  content: counter(css-counter) ". ";
}

/* preview image - points */
/* tooltip styles based on https://blog.logrocket.com/creating-beautiful-tooltips-with-only-css/ */

.schematic-edit-handler .image-chooser .preview-image .points {
  counter-reset: css-counter 0;
}

.schematic-edit-handler .image-chooser .preview-image .point {
  counter-increment: css-counter 1;
  position: absolute;
}

.schematic-edit-handler .image-chooser .preview-image .point::before {
  background-clip: padding-box; /* ensures the 'hover' target is larger than the visible circle */
  background-color: #7c4c4c;
  border-radius: 50%;
  border: 0.25rem solid transparent;
  color: rgb(236, 236, 236);
  box-shadow: 0 -2px 0 rgba(0, 0, 0, 0.1) inset;
  content: counter(css-counter);
  text-align: center;
  line-height: 1.75rem;
  font-weight: bolder;
  display: block;
  height: 1.75rem;
  position: absolute;
  transform: translate(-50%, -50%);
  width: 1.75rem;
  z-index: 1;
}

.schematic-edit-handler .image-chooser .preview-image .point .label {
  opacity: 0; /* hide by default */
  position: absolute;

  /* vertically center */
  top: 50%;
  transform: translateY(-50%);

  /* move to right */
  left: 100%;
  margin-left: 1.25rem; /* and add a small left margin */

  /* basic styles */
  width: 5rem;
  padding: 5px;
  border-radius: 5px;
  background: #000;
  color: #fff;
  text-align: center;
  transition: opacity 300ms ease-in-out;
  z-index: 10;
}

.schematic-edit-handler .image-chooser .preview-image .point:hover .label {
  opacity: 1;
}
Enter fullscreen mode Exit fullscreen mode

5f - Validation & congrats

  • At this point, you should be able to load the Snippet with some existing points and once the JS runs see those points over the image.
  • These points should align visually with the same points shown in the public-facing page (frontend) when that Schematic is used.
  • Back in the Wagtail editor, we should be able to add/delete/reorder points with the InlinePanel UI and the points over the image should update each time.
  • We should also be able to adjust the label, the number fields bit by bit and see the points also updated.
  • Try to break it, see what does not work and what could be improved, but congratulate yourself for getting this far and learning something new!

Final with points showing over the preview image

Part 6 (Bonus) - Drag & Drop!

  • If you want to go down the rabbit hole further, grab yourself a fresh shot of espresso or pour an Aeropress and sit down to make this editing experience even more epic.
  • We will be using the HTML Drag & Drop API here and it is strongly recommended you read through the MDN overview before proceeding.
  • There are some caveats, we are working with a kind of lower-level API and there are browser support considerations to make.
  • Ideally, we would pull in another library to do this for us but it is probably better to build it with plain old Vanilla JS first and then enhance it later once you know this is a good thing to work on.

6a - Add more data attributes to the point template

  • At this point, you probably can tell that data attributes are our friend with Stimulus and Django so let's add some more.
  • In templates/schematics/edit_handlers/schematic_edit_handler.html we will update our template (which gets used to generate the li point element).
  • We have added data-action="dragstart->schematic-edit-handler#pointDragStart dragend->schematic-edit-handler#pointDragEnd" - this is the data-action from Stimulus showing off how powerful this abstraction is. Here we add two event listeners for specific events and no need to worry about addEventListener as it is done for us.
  • We also add draggable="true" which is part of the HTML Drag & Drop API requirements.
<div class="schematic-edit-handler" data-controller="schematic-edit-handler">
  <template data-schematic-edit-handler-target="imagePointTemplate">
    <li
      class="point"
      data-schematic-edit-handler-target="imagePoint"
      data-action="dragstart->schematic-edit-handler#pointDragStart dragend->schematic-edit-handler#pointDragEnd"
      draggable="true"
    >
      <span class="label"></span>
    </li>
  </template>
  {% extends "wagtailadmin/edit_handlers/object_list.html" %}
</div>
Enter fullscreen mode Exit fullscreen mode

6b - Update the SchematicEditHandler Controller to handle drag / drop behaviour

  • Firstly, we need to handle the drag (picking up) an element, these events are triggered by the data-action set above.
  • pointDragStart - this will tell the browser that this element can 'move' and that we want to pass the dataset.id the eventual drop for tracking. We also make the element semi-transparent to show that it is being dragged, there are lots of other ways to visually show this but this is just a basic start.
  • pointDragEnd - resets the style opacity back to normal.
  • In the connect method we call a new method setupImageDropHandlers, this does the job of our data-action attributes but we cannot easily, without a larger set of Wagtail class overrides, add these attributes so we have to add the event handlers manually.
  • setupImageDropHandlers - finds the preview image container and adds a listener for 'dragover' to say 'this can drop here' and then the 'drop' to do the work of updating the inputs.
  • addEventListener("drop"... does a fair bit, essentially it pulls in the data from the drag behaviour, this helps us find what InlinePanel child we need to update. We then work out the x/y percentages of the dropped point relative to the image preview container and round that to 2 decimal places. The x/y values are then updated in the correct fields.
  • A reminder that when we update the fields programmatically, the 'change' event is NOT triggered, so we finally have to ensure we call updatePoints to re-create the points again over the image container.
  • You can now validate this by actually doing drag & drop and checking things get updated correctly in the UI, save the values and check the front-facing page.
class SchematicEditHandler extends Controller {
    // ... targets

    connect() {
      this.setupImageInputObserver();
      this.setupImageDropHandlers();
      this.updatePoints();
    }

    /**
     * Once a new point target (for each point within the inline panel) is connected
     * add an event listener to the delete button so we know when to re-update the points.
     *
     * @param {HTMLElement} element
     */
    pointTargetConnected(element) {
      const deletePointButton = element
        .closest("[data-inline-panel-child]")
        .querySelector('[id*="DELETE-button"]');

      deletePointButton.addEventListener("click", (event) => {
        this.updatePoints(event);
      });
    }

    /**
     * Allow the point to be dragged using the 'move' effect and set its data.
     *
     * @param {DragEvent} event
     */
    pointDragStart(event) {
      event.dataTransfer.dropEffect = "move";
      event.dataTransfer.setData("text/plain", event.target.dataset.id);
      event.target.style.opacity = "0.5";
    }

    /**
     * When dragging finishes on a point, reset its opacity.
     *
     * @param {DragEvent} event
     */
    pointDragEnd({ target }) {
      target.style.opacity = "1";
    }

    // setupImageInputObserver() { ...


    /**
     * Once connected, set up the dragover and drop events on the preview image container.
     * We are unable to easily do this with `data-action` attributes in the template.
     */
    setupImageDropHandlers() {
      const previewImageContainer = this.imageInputTarget
        .closest(".field-content")
        .querySelector(".preview-image");

      previewImageContainer.addEventListener("dragover", (event) => {
        event.preventDefault();
        event.dataTransfer.dropEffect = "move";
      });

      previewImageContainer.addEventListener("drop", (event) => {
        event.preventDefault();

        const inputId = event.dataTransfer.getData("text/plain");
        const { height, width } = previewImageContainer.getBoundingClientRect();

        const xNumber = event.offsetX / width + Number.EPSILON;
        const x = Math.round(xNumber * 10000) / 100;
        const yNumber = 1 - event.offsetY / height + Number.EPSILON;
        const y = Math.round(yNumber * 10000) / 100;

        const inlinePanel = document
          .getElementById(inputId)
          .closest("[data-inline-panel-child]");

        inlinePanel.querySelector("[data-point-x]").value = x;
        inlinePanel.querySelector("[data-point-y]").value = y;

        this.updatePoints(event);
      });
    }

    // updateImage(newValue) { ... etc & rest of controller

Enter fullscreen mode Exit fullscreen mode

Finishing Up & Next Steps

  • You should now have a functional user interface where we can build a schematic snippet with points visually shown over the image in the editor and in the front-facing page that uses it.
  • We should be able to update the points via their fields and if you did step 6, via drag and drop on the actual points within the editor.
  • I would love to hear your feedback on this post, let me know what issues you encountered or where you could see improvements.
  • If you liked this, please add a comment or reaction to the post or even shout me a coffee.
  • You can see the full working code, broken up into discrete commits, on my schematic-builder tutorial branch.

Further Improvements

Here are some ideas for improvements you can give a go at yourself.

  • Add colours for points to align with the colours in the inline panels so that the point/field mapping can be easier to work with.
  • Add better keyboard control, focusable elements and up/down/left/right 'nudging', a lot of this can be done via adding more data-action attributes on the point template and working from there.
  • Add better handling of drag/drop on mobile devices, the HTML5 Drag & Drop API does not support mobile devices great, maybe an external library would be good to explore.

Why Stimulus and not ... other things

I originally built this in late 2021 when doing some consulting, at the time I called the model Diagram but Schematic sounded better.

The original implementation was done in jQuery and adding all the event listeners to the InlinePanel ended up being quite a mess, I could not get a bunch of the functionality to work well that is in this final tutorial and the parts of the JS/HTML were all over the place so it would have been hard to maintain.

Since then, I have been investigating some options for a lightweight JS framework in the Wagtail core codebase. Stimulus kept popping up in discussions but I initially wrote it off and was expecting Alpine.js to be a solid candidate. However, Alpine.js has a much larger API and also has a large CSP compliance risk that pretty much writes it off (yes, the docs say they have a CSP version but as of writing that is not actually released or working, also it pretty much negates all the benefits of Alpine).

After doing some small things with Stimulus, I thought this code I had written would be a good example of a semi-larger thing that needs to interact with existing DOM and dynamic DOM elements without having to dig into the other JS used by the InlinePanel code.

I do not know where the Wagtail decision will head, you can read more of the UI Technical Debt discussion if you want. However, for lightweight JS interaction where you do not have, or need to have, full control over the entire DOM. Stimulus appears to be a really solid choice without getting in the way. While letting you work in 'vanilla' JS for all the real work and helps you with the common things like targeting elements/initialising JS behaviour and managing event listeners.

Updates

💖 💪 🙅 🚩
lb
LB (Ben Johnston)

Posted on February 20, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related