How Do You Embed Images in a Django Blog From a WYSIWYG Editor?
Background
Getting images into your blog articles can be tricky. More consideration is needed than with the text you type or any other resources that exist as markup or style because these resources will exist elsewhere. Whereas text and html will be stored in a database field, your images are objects that will be stored in a file system and then referenced by an image tag.
Because of this, I'm going to approach images as a two-step solution: First, the image record will be stored in a separate table from the blog article and linked back with a many-to-one ForeignKey relationship. When the user uploads an image, the image will be stored on the file system and the image record will store the URL pointing to this resource so it can be retrieved later. And Second, the URL generated from step one will be entered into the content of the article as an image tag using the WYSIWYG editor.
Models
Let's assume we have some basic model Article that we use to write blogs. The specifics aren't relevant. We just need table to relate our images to.
For our images table, I'll use the following:
from django.db import Models
class ContentImage(models.Model):
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='images')
image = models.ImageField(upload_to='content-images')
caption = models.CharField(blank=True, max_length=255)
alt_text = models.CharField(max_length=255)
First, I tie this table back to my Article model. Notably, the on_delete parameter is set to CASCADE since I don't want images to stick around for articles that no longer exist.
Next, I have my image field. The images themselves will be stored in a sub-directory named 'content-images/' off whatever primary location you have identified in your settings.py as your MEDIA_ROOT. Be sure to set this up if you haven't already.
Also worth noting for the images, you have configure Django to be able to serve media files in development. According to Django, this can be achieved with:
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
# ... the rest of your URLconf goes here ...
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Additionally, you need to have the Pillow library installed to use the Django ImageField. this can be accomplished with pip install pillow.
Last, I have my caption and alt_text fields. I made my caption optional because I may not always wish to accompany an image with a caption. But, I made alt_text required since always having the alt attribute of the img tag set is best practice.
Create, Read, Update & Delete (CRUD)
With our images table we're probably going to want to perform basic CRUD operations. For creations, updates & delete's I used Django Class Based Views and Django Model Forms. Since I used a fairly vanilla implementation without much subclassing, no need to elaborate further here beyond mentioning how I established the model relationship.
Since I want to associate each image with a particular article to make organization and maintenance easier, the class-based views need to be aware of the article we're working with. For create views, I'll pass as a URL parameter the ID of a particular object to the view, retrieve the object, then pass the object to the model form where the association is made. For example:
# views.py
from django.views.generic import CreateView
from django.shortcuts import get_object_or_404
from article.models import Article
class CreateImage(CreateView):
...
def get_article(self):
# retrieve the article
article = get_object_or_404(
Article,
**{'id': self.kwargs['article']}
)
return article
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update({
'article': self.article,
})
return kwargs
def get(self, request, *args, **kwargs)
self.article = self.get_article()
return super().get(request, *args, **kwargs)
# forms.py
from django import forms
from article.models import ContentImage
class ContentImage(forms.ModelForm):
...
def __Init__(self, *args, **kwargs):
self.article = kwargs.pop('article')
super().__init__(*args, **kwargs)
def clean(self):
if not self.instance.article_id:
# only add for creates
self.instance.article = self.article
For updates, we'll want to modify how we retrieve the Article object:
class ContentImageUpdateView(ContentImageCreateView):
...
def get_article(self):
return self.object.article
Since the update is already retrieving the ContentImage object, we simply return the Article object stored in the instance's article field.
Delete's & Update's Don't Remove the Image File
Interestingly, when you delete or update an image record stored in the table, the actual image file the record points to is preserved. This means that overtime image files from records deleted or changed will accumulate. These stranded files probably aren't desirable to have since they're not being used but will still accumulate storage costs and consume resources.
To purge the files when the associated records change or are deleted, I'm going to use a signal that I construct directly. This isn't the only way to achieve the desired result, however. There's a package, django-cleanup that promises the help with just this problem.
I don't have experience using this pack, though. And, the package uses signals so you're not using a different approach. But I'd take a look if your app deals with various files in multiple places. Perhaps you can save a little effort with what abstraction they offer.
Back to my approach. I want to catch two signals: pre_save and pre_delete. Note, these signals come in both the 'pre' and 'post' variety. We want to use the 'pre' form so that way we're still able to retrieve the original resource targeted for deletion. If a change is committed, then we won't be able to find it.
These two signals, pre_save and pre_delete will be used for modifications and deletions respectively. The latter is an easy implementation. We can simply delete the file directly from the instance passed to the signal. The former, pre_save, will need additional logic because there are multiple fields that could have been updated and we only want to delete the file if it's the file itself that's changing. For example, we don't want to delete the image file if we're only updating the caption or the alt_text fields.
Let's take a look at the code for these signals:
from django.db.models.signals import pre_delete, pre_save
from django.dispatch import receiver
from django.shortcuts import get_object_or_404
from article.models import ContentImage
# user deletes image
@receiver(pre_delete, sender=ContentImage)
def delete_content_image(instance, **_kwargs):
instance.image.delete(save=False)
# user updates image
@receiver(pre_save, sender=ContentImage)
def change_content_image(instance, **_kwargs):
if instance.id:
# perform for updates only
old_image = get_object_or_404(
ContentImage,
**{'id': instance.id}
)
if old_image.image.url != instance.image.url:
old_image.image.delete(save=False)
As you can see from the second function, we compare the original object to the image associated with the updated record. If the image URLs aren't equal then we know that the user is intending to update the image file. So, we'll want to make sure the original file is destroyed before it's URL is lost after the update so it doesn't become stranded.
Last, make sure register your signals so Django is listening for them!
from django.apps import AppConfig
class ArticleConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'article'
def ready(self):
# noinspection PyUnresolvedReferences
from . import signals
In the Editor
Now that we have a data model to store our image data, views for CRUD, and signals to clean up orphaned, let's look at how we can use our images in content.
Choosing an Editor
There are a number of common WYSIWYG editors out there. A few popular options include:
This is an incomplete list. There are plenty more. Be careful when searching for editors that you consider the scope of your problem. Many of these editors have a bias towards a specific application. Also, consider what's free and what's premium. Many editors seem harmless until you find out that a specific feature is a premium feature.
I've used a number of different editors for different projects. But I always seem to come back to TinyMCE. The editor gives you plenty out of the box. What I like most is that the editor doesn't try to contaminate your html with proprietary markup like others do. If you don't go too crazy, you can start and end with pretty clean html. Then you can rely on your style sheets rather than insert a bunch of inline style. I think it's cleaner but for your application it may not be a problem.
Last thing I'll say about TinyMCE is if your content contains code snippets, go with this editor. You're able to cleanly use pre/code blocks. While you'd expect other editors to make it easy to use pre/code elements, I haven't found that to be the case.
Using the Editor
Somewhere in your chosen editor's toolbar, there should be a button to embed an image.
Upon select the image icon, we should see a dialogue box for entering the source details.
In the 'Source' field, enter the URL that points to the resource stored in the ContentImage table. And with that, you now have images embedded in your content!
That said, making the URL value easily accessible will be helpful to an efficient workflow. I'll take a look at that next.
Retrieving the Image's URL
To make our lives easier, we want to be able to get at the image's URL quickly and easily. I recommend have a view that displays a table containing the URL, a small preview, and controls to access your Update/Delete operations. Consider something like:
/a-path-to-an-image/ | |
/a-path-to-another-image/ |
I left out a column for tools to not clutter the example, but you get the point. Here we now have means to copy the path we need so it can be used with the editor's embed image widget. Note, I show a relative URL for purposes of example. In production, this is going to be an absolute reference.
While the above is a working solution, we can make this a bit easier with javascript.
Javascript Solution
For my javascript approach, I want to have a button next to the URL (but within the same cell) that will copy it to the clipboard so we can paste it into the widget. Additionally, I'll display a success message in the cell to let us know our operation was successful. But, we'll allow that to timeout so it doesn't linger. Last, we'll wrap this in a try/catch block and show an alert if we run into any problems.
Here's what code I have in mind:
const URLButtons = document.querySelectorAll('.copyURLButton')
URLButtons.forEach(btn => {
btn.addEventListener('click', ()=> {
const tableCell = btn.closest('td')
const imageURL = tableCell.querySelector('.image-url').textContent
try {
navigator.clipboard.writeText(imageURL)
// let the user know url copied, but don't interrupt flow with alert unless error
const message = document.createElement('div')
message.className = 'text-success'
message.textContent = 'URL copied.'
tableCell.appendChild(message)
// remove the message after a while
setTimeout(() => {
tableCell.removeChild(message)
}, 3000)
} catch (e) {
// unable to copy, interrupt flow
alert('Unable to copy URL.')
}
})
})
Final Thoughts
Images are important to constructing quality content. But an image resource adds a bit of complexity to work with. Generally, it's going to be a two-step process (unless you want to really step up the complexity) whereby you upload the image to your system and then embed the image with an img tag pointing to the resource.
During this process, we'll want to make sure we take the appropriate steps so that we don't orphan files and create unnecessary cost.
And lastly, we want to make this process as efficient as possible. The javascript allows us to quickly get the URL onto our clipboard and paste it into the editor's widget.