Laravel10とTinyMCEでブログ作成&画像のアップロードができるか調査した

TinyMCEでブログ 画像アップロード Laravel
※当サイトはアフィリエイト広告を掲載しています。

この度、Laravelでブログ的なサイトを構築できる?と聞かれた案件がありました。

その際に焦点となったのが、エディタ部分です。さすがにただのtextareaのみで実装する訳にはいきません。WordPressのような、いわゆるWYSIWYG(ウィジウィグ)の機能が必要になってきます。

さてどうするかと考えたのですが、少なくとも言えるのは手作りするのは大変&無駄です。予算から考えても論外ということで、JSライブラリを探してみました。

結果的に、TinyMCEのオープンソース版なら商用でも無料で使えそうだと分かったので、その経緯等をご紹介します。

結論として、できるけれどもきちんと作らないと使い勝手はあまりよくなく、WordPressもありかなという結果になりました。

まずは調査

前提

調査にあたり、以下が前提となりました。

  • 商用利用可能
  • 商用でも料金無料
  • 文字の色やサイズを変更可能
  • 文中から画像アップロードが可能

あまりお金をかけられない案件だったので、いわゆる商用フリーで使えるライブラリとして検索にかけてみました。

Lara
Lara

中小企業の案件だと、結構予算が厳しいことが多いです。

候補

調べたところ、以下あたりが定評があり、良さそうだということがわかりました。

  • TinyMCE
  • CKEditor

しかしながら、両方とも商用利用が無料でできるとか、一部機能制限があるだとか、いやいや有料だとか、情報が交錯しておりとてもややこしい!

しかも古い情報が多く、ネット上の情報があまり頼りにならなさそうです。……というわけで、自分なりにこれらのライブラリについて、きちんと公式情報を調べました。

もともとネット上の情報は鵜呑みにせず調べなきゃだめ、というツッコミを自分自身に入れておきます。

冒頭でも触れたとおり、今回はTinyMCEで試作することにしました。

WordPressの旧エディタでも採用されており、分かりやすいということも大きいですね。

TinyMCEは無料で商用利用できるか

まず、無料で商用利用可能かについてです。TinyMCE6はMITライセンスであり、商用利用が可能です。

信用ならない方のために、TinyMCEの料金形態をきちんと調べたのでまとめておきます。

私の情報を鵜呑みにできる方は飛ばしてください。

TinyMCEのプラン

TinyMCEのウェブサイトに行くと、どうやら以下のような料金プランがあるようです。

  • Core: 無料
  • Essential: 67$/月
  • Professional: 130$/月
  • Enterprise: 要見積もり

で、Essensialプラン以上で、Commercial license=商用利用のライセンスということになります。

Lara
Lara

無料のCoreプランでは商用無料で使えないじゃん!

……と思いますよね。しかしながら、これはTinyMCEからAPIを通じて利用する場合のプランです。

このプランは、無料プランでも月に1000回ほどしか呼べないなど、機能に制限があります。

フリープランもある

TinyMCEのフッターにあるリンクから目立ちにくいこちらのページに行くと、Download TinyMCE SDK Nowというボタンがあります。

これを押すとTinyMCEのダウンロードが始まります。

このファイルはSelf-Hostedタイプ、つまり自分のサーバーに置いて利用するタイプのTinyMCEとなります。

オープンソース版とも言われます。

この解凍したディレクトリ内のjs/tinymce/license.txtを見ると、MIT License=商用でも利用できることが分かります。

古い情報があった理由

ネット上には、以下の様な情報もありました。

  • 無料では使えない
  • 改変した場合はソースコードの開示(=Githubへプルリクエスト)が必要
  • 無料で画像アップロードはできない(有料プラグインを使う必要あり)

そのおかげで混乱したのですが、それはバージョンの違い(&ライセンスの違い)からくるものであったようです。

というのも以前のTinyMCEは、LGPLライセンスだった模様。そのため、現状とは異なる情報が散見されました。

こちらを見ると、TinyMCE5系まではLGPLだったっぽいです。

TinyMCE6系はMITライセンスであることがファイルを見て明らかですので、商用利用はできると言えそうです。

加えて、画像アップロードもプラグインなどを使わずに、素のTinyMCEで可能になっていました!渡りに船です。

Lara
Lara

もしもライセンス違反していたら大変なので、めっちゃ調べました。安心してオープンソース版を商用利用できます。

ざっくりLaravelで実装してみる

とりあえずLaravel + TinyMCEでブログっぽい感じで画像アップロードができることだけ、実際に動作確認したいと思います。

ちゃんと実装する目的ではなく、提案用の実験として簡易的に作ります。

Laravelの基本的なインストールを行った直後で、DBの設定も済んでいる状態から始めたいと思います。

私はLaravel10で行いますが、最低限の動作であり、特別なことをしているわけではなありません。そのため他のバージョンのLaravelでも動作する可能性が高いです。

TinyMCEを配置

オープンソース版のTinyMCEを解凍すると、tinymce\js\の中に、またtinymceディレクトリがあります(紛らわしい)。

このディレクトリを、public/の適当な場所に配置します。

今回はテスト用なので最上位、つまりpublic/tinymceに配置しました。

BladeからJSの読み込み時、src="/tinymce/tinymce.min.js"で読み込める場所に配置した……と思ってください。

データベースの準備

ブログ投稿のためのデータベーステーブルを作成します。

php artisan make:migration create_posts_table

database\migrations\2023_11_20_200552_create_posts_table.phpというファイルができました。

このファイルを修正し、とりあえずこんな機能が必要かなと思うようなカラムを追加します。

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('main_image')->nullable();// 後の拡張用
    $table->text('body');
    $table->unsignedBigInteger('category_id');
    $table->boolean('is_published')->default(false);
    $table->softDeletes(); // ソフトデリート用
    $table->timestamps();
});

その後、マイグレーションします。

php artisan migrate

モデルの作成

Post モデルを作成します。

php artisan make:model Post

モデルファイル (App\Models\Post.php) を開き、ソフトデリートを有効にします。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;

    protected $fillable = ['title', 'main_image', 'body', 'category_id', 'is_published'];
}

今回ソフトデリート機能は直接利用しませんが、ゴミ箱に入れるような用途として、後に拡張することを想定しています。

コントローラの用意

以下の2つのコントローラが考えつきます。

  • 記事のCRUD用(作成・読み出し・更新・削除)
  • 画像アップロード用

……が、今回は動作確認したいだけなため、CRUD用コントローラーにアップロードの処理も併せもったものにします。

以下のコマンドで、コントローラーを用意します。

php artisan make:controller PostController

以下の様に編集します。

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    // 投稿一覧表示
    public function index()
    {
        $posts = Post::all();
        return view('posts.index', compact('posts'));
    }

    // 投稿作成フォーム表示
    public function create()
    {
        return view('posts.create');
    }

    // 投稿保存
    public function store(Request $request)
    {
        $validatedData = $request->validate([
            'title' => 'required|max:255',
            'body' => 'required',
            'category_id' => 'required|integer',
            'is_published' => 'sometimes|boolean'
        ]);

        if ($request->hasFile('main_image')) {
            $validatedData['main_image'] = $request->file('main_image')->store('images');
        }

        Post::create($validatedData);

        return redirect()->route('posts.index');
    }

    // 投稿詳細表示
    public function show(Post $post)
    {
        return view('posts.show', compact('post'));
    }

    // 投稿編集フォーム表示
    public function edit(Post $post)
    {
        return view('posts.edit', compact('post'));
    }

    // 投稿更新
    public function update(Request $request, Post $post)
    {
        $validatedData = $request->validate([
            'title' => 'required|max:255',
            'body' => 'required',
            'category_id' => 'required|integer',
            'is_published' => 'sometimes|boolean'
        ]);

        if ($request->hasFile('main_image')) {
            $validatedData['main_image'] = $request->file('main_image')->store('images');
        }

        $post->update($validatedData);

        return redirect()->route('posts.show', $post);
    }

    // 投稿削除
    public function destroy(Post $post)
    {
        $post->delete();
        return redirect()->route('posts.index');
    }

   // アップロード
    public function upload(Request $request)
    {
        $request->validate([
            // 例: 最大2MBの画像ファイルのみ許可
            'file' => 'required|image|max:2048',
        ]);

        $fileName = $request->file('file')->getClientOriginalName();
        $path = $request->file('file')->store('uploads', 'public');

        return response()->json(['location' => "/storage/$path"]);
    }
}

ありふれたCRUDに加え、81-92行目にアップロード処理があります。

アップロード画像はstorage/app/public/uploadディレクトリに保存されます(89行目で指定)。

なお、storeメソッドはディレクトリを作成してくれるので、事前にディレクトリを用意する必要はありません。

フォームリクエストも使わず、バリデーションも簡素にしています。実際にはこのあたりをきちんと実装する必要があります。

シンボリックリンクの作成

アップロード画像はLaravelのストレージを利用してアクセスします。そのため、以下のコマンドをまだ実行していない場合は行っておきます。

php artisan storage:link

これにより、public/storageディレクトリとstorage/app/publicディレクトリの間にシンボリックリンクが作成されます。

ルーティングの設定

routes/web.phpにルーティングを追加します。

<?php

use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});
Route::resource('posts', PostController::class);
Route::post('posts/upload', [PostController::class, 'upload'])->name('posts.upload');
  • 19行目:
    記事のCRUD用。
  • 20行目:
    画像アップロード用(TinyMCEからリクエスト)。後述するTinyMCEのimages_upload_urlオプションで指定するURLと一致させる必要があります。

開発環境ではルートキャッシュをしていないとは思いますが、もしキャッシュしてしまっている場合は以下をターミナルから打ち込み、ルートキャッシュを削除します。

php artisan route:clear

ビューを用意する

各ビューファイルを作成します。以下の場所にディレクトリを作成します(普通にOSやVSCodeから作っても可)。

mkdir resources/views/layouts
mkdir resources/views/posts

resources/views/layouts/app.blade.phpとしてレイアウト用のファイルを作ります。

ここに、TinyMCEのJSファイルも入れちゃいます(背景色が違う部分)。

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>{{ config('app.name', 'Laravel') }}</title>
</head>
<body>
    <div id="app">
        <nav>
            <!-- ナビゲーションバーの内容 -->
        </nav>

        <main>
            @yield('content')
        </main>
    </div>
    <script src="/tinymce/tinymce.min.js"></script>
    <script type="text/javascript">
      tinymce.init({
        selector: "textarea#tinymce",
        height: 500,
        
        
        autoresize_min_height: 300,
        autoresize_max_height: 1500,
        autoresize_bottom_margin: 100,
        plugins: 'autoresize anchor autolink charmap codesample emoticons image link lists media searchreplace table visualblocks wordcount code',
        toolbar: 'blocks | fontsize | bold italic underline strikethrough forecolor removeformat | alignleft aligncenter alignright alignjustify | emoticons link image table | numlist bullist | code',
        block_formats: 'Paragraph=p; Header 1=h3; Header 2=h4; Header 3=h5',
        color_map: [],
        relative_urls: false,  // 相対URLを使用しない
        image_title: true,
        automatic_uploads: true,
        images_upload_url: '/posts/upload?_token={{csrf_token()}}', // Laravelのトークンを含める
        file_picker_types: 'image',
        file_picker_callback: function(cb, value, meta) {
          var input = document.createElement('input');
          input.setAttribute('type', 'file');
          input.setAttribute('accept', 'image/*');
          input.onchange = function() {
            var file = this.files[0];
            var reader = new FileReader();
            reader.readAsDataURL(file);
            reader.onload = function() {
              var id = 'blobid' + (new Date()).getTime();
              var blobCache = tinymce.activeEditor.editorUpload.blobCache;
              var base64 = reader.result.split(',')[1];
              var blobInfo = blobCache.create(id, file, base64);
              blobCache.add(blobInfo);
              cb(blobInfo.blobUri(), {
                title: file.name
              });
            };
          };
          input.click();
        }
      });
      tinymce.suffix = ".min";
      tinyMCE.baseURL = '/tinymce'; // TinyMCEの置き場所
    </script>
</body>
</html>

36行目はアップロードのルートを、61行目は、TinyMCEのディレクトリを指定しています。

次に、resources/views/posts/index.blade.phpとして一覧表示用のファイルを作ります。

{{-- 投稿一覧表示 --}}

@extends('layouts.app')

@section('content')
    <h1>投稿一覧</h1>
    <a href="{{ route('posts.create') }}">新規作成</a>
    <ul>
        @foreach ($posts as $post)
            <li>
                <a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a>
                <a href="{{ route('posts.edit', $post) }}">編集</a>
                <form action="{{ route('posts.destroy', $post) }}" method="POST">
                    @csrf
                    @method('DELETE')
                    <button type="submit">削除</button>
                </form>
            </li>
        @endforeach
    </ul>
@endsection

次に、resources/views/posts/create.blade.phpとして新規投稿フォーム用のファイルを作ります。

{{-- 新規投稿フォーム --}}

@extends('layouts.app')

@section('content')
  <h1>新規投稿</h1>
  <form action="{{ route('posts.store') }}" method="POST" enctype="multipart/form-data">
    @csrf
    <div>
      <label for="title">タイトル:</label>
      <input type="text" name="title" id="title" value="{{ old('title'); }}">
      @error('title')
        <div class="error">{{ $message }}</div>
      @enderror
    </div>
    <div>
      <label for="body">本文:</label>
      <textarea name="body" id="tinymce">{{ old('body'); }}</textarea>
      @error('body')
        <div class="error">{{ $message }}</div>
      @enderror
    </div>
    <div>
      <label for="category_id">カテゴリーID:</label>
      <input type="number" name="category_id" id="category_id" value="{{ old('category_id'); }}">
      @error('category_id')
        <div class="error">{{ $message }}</div>
      @enderror
    </div>
    <div>
        <label for="is_published">公開:</label>
        <input type="checkbox" name="is_published" id="is_published" value="1"
          {{ old('is_published') ? 'checked' : '' }}>
      </div>
    <div>
      <button type="submit">投稿</button>
    </div>
  </form>
  <a href="{{ route('posts.index') }}">一覧に戻る</a>
@endsection

次に、resources/views/posts/show.blade.phpとして詳細表示用のファイルを作ります。

{{-- 投稿詳細表示 --}}

@extends('layouts.app')

@section('content')
    <h1>{{ $post->title }}</h1>
    <p>{!! $post->body !!}</p>
    <a href="{{ route('posts.index') }}">一覧に戻る</a>
@endsection

resources/views/posts/edit.blade.phpとして投稿編集フォーム用のファイルを作ります。

{{-- 投稿編集フォーム --}}

@extends('layouts.app')

@section('content')
  <h1>投稿編集</h1>
  <form action="{{ route('posts.update', $post) }}" method="POST" enctype="multipart/form-data">
    @csrf
    @method('PUT')
    <div>
      <label for="title">タイトル:</label>
      <input type="text" name="title" id="title" value="{{ old('title', $post->title) }}">
      @error('title')
        <div class="error">{{ $message }}</div>
      @enderror
    </div>
    <div>
      <label for="body">本文:</label>
      <textarea name="body" id="tinymce">{{ old('body', $post->body) }}</textarea>
      @error('body')
        <div class="error">{{ $message }}</div>
      @enderror
    </div>
    <div>
      <label for="category_id">カテゴリーID:</label>
      <input type="number" name="category_id" id="category_id" value="{{ old('category_id', $post->category_id) }}">
      @error('category_id')
        <div class="error">{{ $message }}</div>
      @enderror
    </div>
    <div>
      <label for="is_published">公開:</label>
      <input type="checkbox" name="is_published" id="is_published" value="1"
        {{ old('is_published', $post->is_published) ? 'checked' : '' }}>
    </div>
    <div>
      <button type="submit">更新</button>
    </div>
  </form>
  <a href="{{ route('posts.index') }}">一覧に戻る</a>
@endsection

画像のアップロード方法

これでとりあえず動作させることができますので、確認してみましょう。

ブラウザから/posts/createとしてアクセスすることで、投稿画面が表示されます。

以下の赤枠部分は、TinyMICEのオプションで表示をコントロールできるようです。

TinyMICEのオプションで表示部分

画像のアップロードはどこだ!?と探してみたところ、三点リーダ()をクリックすると、画像アップロードができます。

画像のアップロードボタンの場所

次に、以下の様にファイルアップロードボタンから任意のファイルを選択します。Saveを押せばTinyMCEから/post/uploadへリクエストが走ります。

ファイルアップロードボタンから任意のファイルを選択

これでアップロード出来ました。

画像のアップロード完了

このまま保存しようとしても、タイトルが無いのでバリデーションエラーが出ますが、その時も画像は保持されます(消えちゃわないか心配でした)。

もちろんバリデーションに通れば保存もできます。

巨大な画像をアップロード時、ドラッグで縮小できますが、使いづらさを感じます。実務で導入する際は、問題無いか事前に試してみてください。

余談など

これを作る際に気づいたこと、余談等を補足しておきます。

CSRFトークンに困った

app.blade.phpのTinyMCEに関する部分で、アップロードのルートを指定する場所がありました。最初は以下の様にしたのですが、419エラーが発生します。

images_upload_url: '/posts/upload',

これはLaravelのCSRF対策でこうなっています。

ネット上ではapp\Http\Middleware\VerifyCsrfToken.phpを、以下の様にして、CSRFトークンの検証除外するという方法もありました。

protected $except = [
    '/posts/upload',
];

これでも解決できますが、管理画面内のユーザーしか使わない場合ならCSRFトークンの検証を除外しても良いか……という問題もあります。

個人的には他の方法を採りたいなと感じました。

最終的には、CSRFトークンを_tokenというGETパラメータで与えてあげれば419エラーが出ませんでした。

images_upload_url: '/posts/upload?_token={{csrf_token()}}',

本当はJSの中にPHPを埋め込みたくないですが、トークンであることが保証されているので大丈夫かなと。

画像が相対パスになる

画像をアップロードすると、DB保存時に../storage/から始まる相対パスで登録されてしまいます。

そのため、/posts/1などならOKですが、/posts/1/editのように相対パスがずれると画像が表示されないことがわかりました。

調べたところ、TinyMCEのオプションを設定することで、/storage/から始まる公開ディレクトリからの絶対パスで保存ができました。

relative_urls: false,  // 相対URLを使用しない

画像の削除ができない

画像のアップロードは今回行うことができました。しかしながら、画像の削除はできません。

アップロード後、ブログのTinyMCE内でその画像を消した場合、その画像はストレージに残ってしまいます。

DBのbodyカラムから画像URLを抜き出し、使用していない画像を削除するような処理をcronで定期処理する!?なども考えましたが、面倒臭そうです。

今のところベストな方法が見つかっていません。

Lara
Lara

そのままにしちゃう方が楽そう(汗)

ロゴの削除

TinyMCEのロゴがあり、利用者がクリックすると公式サイトが開くため、リファラにより管理画面のURLが流出する恐れがありそうです。

管理画面内であればアクセス制限されるので問題無いとは思いますが……。それに信頼できるプロジェクトとは理解しています。

赤枠の上下2箇所です。

公式サイトの規約には以下の様にあります。

While it is unclear under the MIT open source license to include attribution, we do encourage it. TinyMCE open source is provided free of charge, and is made possible by contributions of the community. The larger the community is, the better TinyMCE will become. Please consider including attribution.

引用: Logo & attribution requirements | Tiny

要約すると、MITオープンライセンスでは帰属を含めることは明記されていないけれど、できれば出典を含めてください。とのことです。

逆に言えば消してもよいようで、以下のようなオプションが見つかったので試しました。

branding: false,

しかしながら、下のロゴは消えましたが上の「Upgrade」ボタンは消えませんでした。

Lara
Lara

それならば……と、branding: trueにしておきました。

ブログとしての使い勝手

最後になりますが、Laravel×TinyMCEのブログを実務案件で使うのは悩ましいなと感じました。

ここまで試作して改めて感じたのは、ブログとして機能させるにはTinyMCE使っとけばOK……とはならないことです。

それっぽいものならとても簡単です。しかし実際の操作感は作り込みに比例して向上します。WordPressのような完成されたものと比較すると当然見劣りしてしまうんですよね。しかも無料なわけで(汗)。

Laravel×TinyMCEが刺さる案件は、意外と限定されそうに思いました。

それにWordPressのGutenberg(ブロックエディタ)に慣れている方であれば、「これ古いWordPressのやつだ!」となってしまう可能性もありそうですね……。

まとめ

以上、Laravelでブログ機能&画像アップロード機能について調べてみました。

本文中でも触れていますが、できることはできましたが、結局はきちんと作り込まないとブログとしては機能しませんし、工数も掛かりそうです。

ブログとLaravelの連携が活かせるシーンでは良いですが、現実的には意外と難しいのかな、とも思いました。

参考にしていただければ幸いです。

Lara
Lara

今回はLaravelと切り離してWordPressで構築することも提案しようと思います。

コメント

タイトルとURLをコピーしました