PythonDjangoDjangoカスタムウィジェット

Djangoで、プレビュー付き画像アップロード欄を作る

概要

DjangoでImageFieldを使うと画像のアップロード欄が作られますが、プレビューは表示されません。今回はウィジェットを自作し、プレビュー付きの画像アップロード欄を作ります。

管理画面でのデモ
管理画面でのデモ

自作ページでのデモ
自作ページでのデモ

もちろん、更新時もプレビュー表示されています。 管理画面での更新時 自作ページでの更新時

アップロード画像のプレビューですが、少し離れたところに表示したいという場合も多くありますので、それにも対応しています。 離れた場所にプレビュー

ウィジェットの自作

ファイルアップロード欄は、DjangoのデフォルトではClearableFileInputというウィジェットが使われています。今回はこれを改良して、プレビュー付きのファイルアップロード欄を作ります。

まず、widgets.pyを作成し、次のようにしておきましょう。

from django import forms


class FileInputWithPreview(forms.ClearableFileInput):
    """プレビュー表示されるinput type=file"""
    template_name = 'app/widgets/file_input_with_preview.html'

    class Media:
        js = ['app/preview.js']

    def __init__(self, attrs=None, include_preview=True):
        super().__init__(attrs)
        if 'class' in self.attrs:
            self.attrs['class'] += ' preview-marker'
        else:
            self.attrs['class'] = 'preview-marker'
        self.include_preview = include_preview

    def get_context(self, name, value, attrs):
        context = super().get_context(name, value, attrs)
        context['widget'].update({
            'include_preview': self.include_preview,
        })
        return context

ClearableFileInputを継承した、FileInputWithPreviewウィジェットを定義しています。

ウィジェットがどんなHTMLになるかを、テンプレートを使って指定することができます。このウィジェットはfile_input_with_preview.htmlを使って描画されるということです。

class Mediaは、このウィジェットを使う際にcssやjsの読み込みが必要な場合に使います。{{ form.media }}とすると、今回であればpreview.jsが自動的に読み込まれるようになります。

上で離れた場所にプレビューを表示させるGIFがありましたが、それを実現するために幾つかのハックが必要で、それが__init__とget_contextメソッドの上書きです。また、CSSクラスとしてpreview-markerを必ず持つようにもしています。

ウィジェットのテンプレートを作りたいのですが、一つ注意があります。Django、テンプレートの探索順序で詳しく説明していますが、プロジェクト直下にtemplatesディレクトリを置くような作り(settings.pyでDIRSに何か指定している)ならば、次のような設定をする必要があります。

INSTALLED_APPS = [
    'app.apps.AppConfig',  # マイアプリケーション
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.forms',  # 足す
]
...
...
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'

それでは、templates内のアプリケーションディレクトリ内にwidgetsというディレクトリを作り、中にfile_input_with_preview.htmlを置きます。中身は次のようにします。

{% if widget.is_initial %}{{ widget.initial_text }}: <a href="{{ widget.value.url }}">{{ widget.value }}</a>{% if not widget.required %}
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}">
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}<br>
{{ widget.input_text }}:{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} data-target="{{ widget.name }}-preview" data-initial="{{ widget.value.url }}">
{% if widget.include_preview %}
    <br><img src="" id="{{ widget.name }}-preview"  class="default-preview-img">
{% endif %}

前半はClearableFileInputがデフォルトで使うclearable_file_input.htmlと同じですが、後半はプレビュー用の属性などを付け足しています。

プレビューの処理はJavaScriptで行います。preview.jsを作ります。

document.addEventListener('DOMContentLoaded', e => {
    for (const fileInput of document.querySelectorAll('input.preview-marker')) {
        const img = document.getElementById(fileInput.dataset.target);
        if (img) {
            fileInput.addEventListener('change', e => {
                img.src = window.URL.createObjectURL(e.target.files[0]);
            });
            const initialURL = fileInput.dataset.initial;
            if (initialURL) {
                img.src = initialURL;
            }
        }
    }
});

自作ウィジェットで作られたinput type="file"要素に対して、ファイルが選択されたらプレビュー用のimg要素に画像を表示しろ、といったイベントを設定しています。また、データの更新ページでは既にアップロードされた画像があるはずなので、それの初回プレビュー表示処理などもしています。

使い方

準備が終わったので、使い方を説明していきます。端的に言うと、ImageFieldのウィジェットを今回のウィジェットに変更すればOKです。

モデルフォームを利用しているならば、次のようにしてウィジェット変更します。

class ImageForm(forms.ModelForm):

    class Meta:
        model = Image
        fields = '__all__'
        widgets = {
            'file': FileInputWithPreview,
        }

通常のフォームの場合は、次のようになるでしょう。

class ImageForm(forms.Form):
    file = forms.ImageField(widget=FileInputWithPreview)

後は、このフォームをテンプレートに表示するだけで、アップロード欄がプレビュー表示されます。{{ form.media }}が必要なので、それだけ忘れないようにしましょう。

    <form action="" method="POST" enctype="multipart/form-data">
        {{ form.as_p }}
        {% csrf_token %}
        <button type="submit">送信</button>
        {{ form.media }}
    </form>

管理画面のアップロード欄をプレビューしたい場合は、admin.pyを少し変更するだけです。

from django.contrib import admin
from .forms import ImageForm
from .models import Image


class ImageAdmin(admin.ModelAdmin):
    form = ImageForm


admin.site.register(Image, ImageAdmin)

これは管理画面のフォームをImageFormにすることで、管理画面で使われているアップロードウィジェットをFileInputWithPreviewに変更しています。

もしくは、そのページの全ての画像アップロード欄(models.ImageField)をFileInputWithPreviewに変更していいならば、次のような書き方もできます。

from django.contrib import admin
from django.db import models
from .models import Image
from .widgets import FileInputWithPreview


class ImageAdmin(admin.ModelAdmin):
    formfield_overrides = {
        models.ImageField: {'widget': FileInputWithPreview},
    }


admin.site.register(Image, ImageAdmin)

プレビュー用img要素を自分で配置する

{{ form.file }}のようにしてアップロード欄を表示すると、プレビュー用のimg要素が含まれています。しかし、プレビュー用のimg要素を自分で任意の場所に配置したいケースも考えられます。その場合は、まずウィジェットを変更する際に次のようにします。

FileInputWithPreview(include_preview=False)

これにより、デフォルトで表示されていたプレビューimg要素が出力されなくなります。後はテンプレートで、次のようにimg要素を好きな場所に配置してください。

    <form action="" method="POST" enctype="multipart/form-data">
        {{ form.as_p }}
        {% csrf_token %}
        <button type="submit">送信</button>
        {{ form.media }}
    </form>

    色々な要素が間にある...

    <img id="file-preview"><!-- プレビュー要素をここにおいたよ -->

プレビューimg要素のid名は、フィールド名-previewとする必要があります

デフォルトプレビューimg要素のcss設定

デフォルトで出力されるimg要素は、default-preview-imgというcssクラス名が付けられています。なので、cssをカスタマイズしたい場合は次のように変更できます。

.default-preview-img {
    max-width: 100%;
    height: auto;
}

ソースコードのダウンロード

https://github.com/naritotakizawa/django-preview-image