【アプリ開発日記42週目】テキストエディタ作成&そのまま保存
おはようございます。健康食品は薬じゃないから治るわけではないよ、らしいです。
今回はテキストエディタの作成。過去にもnextjsで一から作っていましたが、その欠点は「入力と加工する場所が違う」ということ。
入力はinputでしかできず、input内の文字は一部だけ赤文字などにできないため、どうしても分ける必要があったのです。
でも、しばらく使っていると「wordやnoteみたいにその場で加工もできないかなぁ」とつい思ってしまいます。特に長い文章だと、比較だけでも一苦労なので。
ということでさっそく作ってきました!
1,複数画像アップロード
文章を見やすくするために必須な画像。今までチュートリアルレベルしか触れてこなかったので記憶は愚か、「複数の画像が入ってる時はどうやって保存するんだ?」とわからないことばかりです。
なので、今回は備忘録も兼ねて、最後に簡単なアップロードウェブアプリを作成した時の流れとコードを記載しました。
結果的には投稿と画像専用のテーブルを別々に作成し、1対多の外部キーを使用することで、投稿データのテーブルを大きくせずに何枚でも画像を保存できるようにしています。参考サイトもコードと一緒に貼っているので、実装に興味のある方はぜひご覧ください。
なお、テキストファイルに加えて画像ファイルもPOSTするため、formタグに「enctype="multipart/form-data"」が必須です!
2,テキストエディタの作成
本題です。最終的には、ここに画像添付できるようにしながら、その画像をサーバーに保存できるようにしていきます。
nextjsで作成していた時は<span>とgetSelection()を多用して選択箇所を置換する、という方法をとっていましたが、この時の欠点が
入力と編集を分ける必要がある
リスト(この部分みたいな)を作れない
画像を載せられない
でした。3つめは直接<img>を書き込むことで対応していたのですが、それだと画像は別で保存する手間がかかる上に、何よりhtmlなどに馴染みのない方にとっては使いづらいのです。
最初はwordファイルを送信してもらうということも考えたのですが、これもまた自動処理を作成したとは言え、新しい投稿が来るたびに毎回htmlに変換する必要があり、現実的とは言いにくい。
そこでテキストエディタのライブラリがないか探していたところ…ありました!「Editor.js」です。
そして実装したものがこちら。
静止画なのでわかりにくいですが、上記の欠点を一通り補えるようになっています! 導入も非常にシンプルで日本語の説明サイトもいくつかあるので、実装を考えている方はぜひ試してみてください。
これで文章の作成もできました。あとはこれを文章・画像それぞれ保存できるようにすれば完成ですね。
3,データの保存
まず文章からです。この類の保存は特別な点もないので、ここでは割愛します。テキストエディタ内の文字の部分のみ javascript で取得し、確認用のスペースにタグごと表示。その取得内容を保存すれば、それを取得したときもリストなどの形式を保ったまま表示できます。
※2022/10/30 追記:この方法だとタグごと文字列で保存はできますが、再編集する際にブロック形式を再現できません。ブロック形式で保存するためにはsave()が必要ですが、この方法は次週(43週目)に記載しています。
問題なのが、画像の保存。プラグインで画像を文字列の中に入れることはできますが、Blob形式で一時的にブラウザ上に保存されるだけで、データベースへの保存はできません(方法を知らないだけかもしれませんが…)。
そこで、1の時に行った画像保存の方法を活かしていきます。とは言え今のままではそもそもinputごと削除されてしまうので、もとのプラグインをさらにカスタマイズしていきます。
具体的には、以下のリポジトリのコードからinput削除のコードを削除し、CDNではなくローカルのjsファイルとしてhtmlから読み込みます。
▼
まだプラグインの作り方はあやふやですが、基本的にどれも公式ドキュメントのオーバーラップになっているので、慣れれば気軽に作れるようになりそう?です。
これで画像ファイルが封入されたinputタグを残すことができました! 実際のエディタでは必要ないので、同じくプラグインに'hidden'クラスを追加する処理を加えて非表示にしておきます。
この状態で1で作成した<form>内に入れ、保存できるか試してみます!
投稿の文字データと画像を入力して submit ボタンを押すと……
しっかり保存されました!
画像も保存されてます!
ここまででテキストエディタを作成、その文字と画像も保存できるようになりました。あとは保存した文字の中にある<img>の href を保存される時に blob から画像の名前にすれば、文章の間にうまく表示されそうですね。(上記はあくまで文章とそれに紐づいた画像を別々で表示)
noteのコードを見ていて思ったのですが、画像はnote.comとは別のサーバーに保存されてるみたいです。たしかにいずれアクセスが増えたときの負荷分散は必要に…なりますように。目指せnote、追い越せnote。
最後の仕上げいきましょう!
4,完成
あとは1~3までの合体すれば完成です! 保存する前に内容を再確認する画面を追加しました。
おわりに
テキストエディタを一から作成・その内容を形を維持したままデータベースに入れたことはなかったので、プラグインに頼りながらも右往左往しました。今こうやってnoteを触っていて「すげー!」ってなります笑
一方で、テキストエディタを使えるようになると、ユーザーが一層参加できるようになるので出来ることも広がりますね。少しずつ今回のテキストエディタも進化させていきます。
ではでは!
コード・参考サイト
上記の記事を参考にさせていただきました。このサイトではAjaxで解説されているので、ここではフォームのsubmitを使った一例を挙げてみます。
まずmodels.pyとforms.py。1対多の外部キーが肝です。
--- models.py ---
from django.db import models
from django.utils import timezone
class Topic(models.Model):
comment = models.CharField(verbose_name="コメント",max_length=2000)
def images(self):
return TopicImage.objects.filter(topic=self.id).order_by("dt") #上から順に 123456
def __str__(self):
return self.comment
class TopicImage(models.Model):
dt = models.DateTimeField(verbose_name="投稿日時",default=timezone.now)
topic = models.ForeignKey(Topic,verbose_name="トピック",on_delete=models.CASCADE)
image = models.ImageField(verbose_name="画像",upload_to="testapp/topic_image/comment")
def __str__(self):
return self.topic.comment
--- forms.py ---
from django import forms
from .models import Topic,TopicImage
class TopicForm(forms.ModelForm):
class Meta:
model = Topic
fields = [ "comment" ]
class TopicImageForm(forms.ModelForm):
class Meta:
model = TopicImage
fields = [ "topic","image" ]
settings.pyに画像の保存場所などを追記
--- settings.py ---
from .settings_local import *
INSTALLED_APPS = [
***
'testapp.apps.TestappConfig',
]
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
HTMLとJavascript、それを表示するためのviews.pyも書いていきます。<form>に「enctype="multipart/form-data"」を書くところがミソ。content.htmlを作ることで、保存した画像も見れるようにします。
以下、submitで保存する場合です。
--- index.html ---
{% load static %}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>簡易掲示板</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
<script src="{% static 'js/script.js' %}"></script>
</head>
<body>
<main class="container">
<form id="form_area" action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<textarea id="textarea" class="form-control" name="comment"></textarea>
<!--TODO:後はこの部分をJSで増やしたり減らしたりする。-->
<div id="image_input_area">
<input class="image_input" type="file" name="image">
</div>
<input id="submit" type="submit" value="送信">
</form>
<div id="content_area">{% include "testapp/content.html" %}</div>
</main>
</body>
</html>
--- content.html ---
{% for topic in topics %}
<div class="border">
<div>{{ topic.comment }}</div>
<div>
{% for topic_image in topic.images %}
<div>
<img src="{{ topic_image.image.url }}" alt="画像" style="max-width:100%;">
</div>
{% endfor %}
</div>
<form action="{% url 'testapp:single_ajax' topic.id %}">
<input class="btn btn-outline-danger trash" type="button" value="削除">
</form>
</div>
{% endfor %}
--- content.html ---
{% for topic in topics %}
<div class="border">
<div>{{ topic.comment }}</div>
<div>
{% for topic_image in topic.images %}
<div>
<img src="{{ topic_image.image.url }}" alt="画像" style="max-width:100%;">
</div>
{% endfor %}
</div>
<form action="{% url 'testapp:single_ajax' topic.id %}">
<input class="btn btn-outline-danger trash" type="button" value="削除">
</form>
</div>
{% endfor %}
--- script.js ---
// load : ページ全体が、スタイルシートや画像などのすべての依存するリソースを含めて読み込まれたときに発生
window.addEventListener("load", function () {
$(document).on("click", ".trash", function(){ trash(this); });
// 新たな画像input箇所を作成
$(document).on("input", ".image_input", function(){
$("#image_input_area").append('<input class="image_input" type="file" name="image">');
})
});
function trash(elem){
let form_elem = $(elem).parent("form");
let url = $(form_elem).prop("action");
$.ajax({
url: url,
type: "DELETE",
dataType: 'json'
}).done( function(data, status, xhr ) {
if (data.error){
console.log("ERROR");
}
else{
$("#content_area").html(data.content);
}
}).fail( function(xhr, status, error) {
console.log(status + ":" + error );
});
}
--- viwes.py ---
from django.shortcuts import render, redirect
from django.views import View
from django.http.response import JsonResponse
from django.template.loader import render_to_string
from .models import Topic
from .forms import TopicForm,TopicImageForm
class IndexView(View):
def get(self, request, *args, **kwargs):
topics = Topic.objects.all()
context = { "topics":topics }
return render(request,"testapp/index.html",context)
def post(self, request, *args, **kwargs):
data = { "error":True }
form = TopicForm(request.POST)
#ここでコメントを保存
if not form.is_valid():
print(form.errors)
print("Validation Error")
return JsonResponse(data)
topic = form.save()
#ここで複数指定した画像を追記。
images = request.FILES.getlist("image")
print(images)
for image in images:
upload_image_file = { "image":image }
upload_image_name = { "topic":topic.id,"image":str(image) }
form = TopicImageForm(upload_image_name,upload_image_file)
if form.is_valid():
print("バリデーションOK")
form.save()
else:
print("バリデーションNG")
print(form.errors)
context = {}
context["topics"] = Topic.objects.all()
data["error"] = False
data["content"] = render_to_string("testapp/content.html",context,request)
return redirect('/')
index = IndexView.as_view()
viwes.pyでは.getlist()を使用。同じname属性を配列で取得できます。出力すると
images = request.FILES.getlist("image")
print(images)
[<TemporaryUploadedFile: IMG_5319.PNG (image/png)>, <TemporaryUploadedFile: IMG_5273.PNG (image/png)>]
最後に、これらをつなげるurls.pyを書けば完成です!
プロジェクトの方の「urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)」を忘れるとmediaフォルダ内の画像を参照できないので忘れないようにしてください!
--- project / urls.py ---
from django.contrib import admin
from django.urls import path,include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include("testapp.urls"), name="testapp"),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
--- app / urls.py ---
from django.urls import path
from . import views
app_name = "testapp"
urlpatterns = [
path('', views.index, name="index"),
path('<int:pk>/', views.index, name="single"), # 削除ボタン
]
おつかれさまでした!
全体(複数画像アップロード・Editor.jsのプラグイン)