TinyMCEにPDFアップロード機能を実装する方法

# デモ

<p class="codepen" data-height="300" data-default-tab="js,result" data-slug-hash="MWrYeKX" data-user="qwe001" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/qwe001/pen/MWrYeKX">
  TinyMCE PDF File Upload Example</a> by qwe001 (<a href="https://codepen.io/qwe001">@qwe001</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

# 要件

* WYSIWYGエディタ上で、PDFファイルをアップロードできること
* PDFをアップロードしたら、アンカータグが生成され、PDFへのリンクが付くこと

$\tiny{予算も時間もないので有償ライブラリ(MoxieManagerなど)は使わない。}$
$\tiny{エンジニア工数だけで完結する範囲で実装すること(小声)}$

# 実装(BASE64エンコード版)

PDFファイルをサーバーにアップロードせずに、文字列として持ちたい場合はコチラ。
サクッと動作確認したい方は、上記のデモを試してみてください

```javascript
function initTinyMCE()
{
  var options = {
    selector: 'textarea',
    plugins: 'link code',
    toolbar: 'undo redo | link | code',
    automatic_uploads: true,
    file_picker_types: 'file', // e.g. "file,image,media"
    file_picker_callback: function (callback, value, meta) {
      if (meta.filetype === 'file') { // anchor link
        //callback('https://www.google.com/logos/google.jpg', { text: 'My text' });
        uploadFileHandler(callback, value, meta);
      }
      if (meta.filetype === 'image') {
        //callback('https://www.google.com/logos/google.jpg', { alt: 'My alt text' });
      }
      if (meta.filetype === 'media') {
        //callback('movie.mp4', { source2: 'alt.ogg', poster: 'https://www.google.com/logos/google.jpg' });
      }
    }
  };

  tinymce.init(options);
}

function uploadFileHandler(callback, value, meta)
{
   var input = document.createElement('input');
   input.setAttribute('type', 'file');
   input.setAttribute('accept', 'application/pdf');
   input.click();

   input.onchange = function () {
     var file = this.files[0];

     var reader = new FileReader();
     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);

       callback(blobInfo.blobUri(), { title: file.name });
     };
     reader.readAsDataURL(file);
   };
}

initTinyMCE();
```

# 実装(サーバーにアップロードする)

多くの人が求めている(求められている)実装はこっちだと思います。
基本的には、画像アップのやり方と同じです。
[昔はプラグイン入れてやってた](https://qiita.com/qwe001/items/9a86839db4da5a7ab763)なあ～ってしみじみ思った。

## フロントエンド

CSRF対策が不要な場合は、CSRF関係の記述は省いてもらっても構いません

```javascript
function initTinyMCE()
{
  var options = {
    selector: 'textarea',
    plugins: 'link code',
    toolbar: 'undo redo | link | code',
    //automatic_uploads: true,
    file_picker_types: 'file', // e.g. "file,image,media"
    file_picker_callback: function (callback, value, meta) {
      if (meta.filetype === 'file') { // anchor link
        //callback('https://www.google.com/logos/google.jpg', { text: 'My text' });
        uploadFileHandler(callback, value, meta);
      }
      if (meta.filetype === 'image') {
        //callback('https://www.google.com/logos/google.jpg', { alt: 'My alt text' });
      }
      if (meta.filetype === 'media') {
        //callback('movie.mp4', { source2: 'alt.ogg', poster: 'https://www.google.com/logos/google.jpg' });
      }
    }
  };

  tinymce.init(options);
}

function uploadFileHandler(callback, value, meta)
{
  var allowedFileTypes = 'application/pdf';
  var input = document.createElement('input');

  input.setAttribute('type', 'file');
  input.setAttribute('accept', allowedFileTypes);
  input.click();

  input.onchange = function() {
    var file = this.files[0];

    // API側(CodeIgniter側)に送るデータを準備
    var postData = new FormData();
    // PDFデータを送る
    postData.append('body_pdf', file, file.name); // $_FILES['body_pdf'] => array

    var tokenName = csrfTokenName();
    var currentTokenVal = csrfTokenVal();

    // CSRFトークンを送る(これを送らないとAPI側で処理を受け付けない)
    if(isCsrfTokenFieldExists()){ // if csrf_protection enabled
      postData.append(tokenName, currentTokenVal);
    }

    var xhr = new XMLHttpRequest();
    xhr.withCredentials = false;

    var filesUploadUrl = '/path/to/your/api/upload_pdf';
    xhr.open('POST', filesUploadUrl); // connect ajax

    xhr.onload = function(){
      if (xhr.status < 200 || xhr.status >= 300) {
        callback('HTTP Error: ' + xhr.status); // show error to href
        return;
      }

      var json = JSON.parse(xhr.responseText);

      // API側(CodeIgniter側)のレスポンスJSONにlocationプロパティがない時
      if (!json || typeof json.location != 'string') {
        callback('Invalid JSON: ' + xhr.responseText); // show error to href
        return;
      }

      var fileUrl = json.location;
      var attrs = {
        //"text" : "", // リンク元テキスト
        "title" : file.name // title属性
      };

      // TinyMCEのリンクモーダルにhrefやtitleなどの値を返す
      callback(fileUrl, attrs);

      // CSRFトークンを再生成(これをしないとフォームの送信時にCSRFエラーになるため)
      var newCsrfTokenVal = json[tokenName]; // e.g. json.csrf_token
      if(newCsrfTokenVal){
        csrfTokenRegenerate(newCsrfTokenVal);
      }
    };

    xhr.send(postData);
  };
}

function csrfTokenName()
{
  return "csrf_token";
}

function csrfTokenField()
{
  return $('input[name=' + csrfTokenName() + ']');
}

function isCsrfTokenFieldExists()
{
  return csrfTokenField().length > 0 ? true : false;
}

function csrfTokenVal()
{
  return csrfTokenField().val();
}

function csrfTokenRegenerate(newVal)
{
  return csrfTokenField().val(newVal);
}

initTinyMCE();
```

## サーバーサイド

サーバーサイドの実装はCodeIgniter3を使ってます

```php
class Api_Controller extends CI_Controller
{
    public function __construct()
    {
        parent::__construct();
        $this->config->load('config');
    }

    public function upload_pdf()
    {
        $fileDir = base_url($this->uploadPdfDir());

        $inputName = "body_pdf";
        $file = $this->uploadFile($inputName);
        $filePath = $fileDir . $file['file_name'];

        // フロント(AJAX)に返すデータを準備
        $res = array(
            'location' => $filePath,
            'status' => 'success',
            'response' => 200
        );

        $isCsrfProtectionEnabled = $this->config->item('csrf_protection'); // @see application/config/config.php $config['csrf_protection'];
        if($isCsrfProtectionEnabled){
            $csrfTokenName = $this->security->get_csrf_token_name();
            $csrfTokenValue = $this->security->get_csrf_hash();
            $res[$csrfTokenName] = $csrfTokenValue;
        }

        header("Content-Type: application/json; charset=utf-8");
        echo json_encode($res);
        return TRUE;
    }

    private function uploadPdfDir()
    {
        return "/path/to/upload/dir/pdf/";
    }

    protected function uploadFile($fieldName = "image")
    {
        $ret = array();

        $config = array();
        $defaultConfig = $this->loadDefaultConfigUpload();

        $config = array_merge($defaultConfig, $config);

        switch($fieldName){
            case "image" :
                $config['upload_path'] = $this->uploadImgDir();
                $config['allowed_types'] = 'jpg|jpeg|png|gif';
                break;
            case "body_pdf" : // HTMLエディタからのPDFアップロード
                $config['upload_path'] = $this->uploadPdfDir();
                $config['allowed_types'] = 'pdf';
                break;
            default :
                $config['upload_path'] = $this->uploadImgDir();
        }

        $this->makeDirectioryIfNotExists($config['upload_path']);

        $this->upload->initialize($config);
        if($this->upload->do_upload($fieldName)){
            $info = $this->upload->data();
            $filePath = $info['file_path'];
            $fileName = $info['file_name'];

            $ret = array(
                'info' => $info,
                'file_name' => $fileName,
                'is_success' => TRUE,
                'response' => 200,
                'message' => 'successfully uploaded ' . $filePath . $fileName,
            );
        }
        else {
            $info = $this->upload->display_errors();

            $ret = array(
                'info' => $info,
                'file_name' => NULL,
                'is_success' => FALSE,
                'response' => 500,
                'message' => 'upload failed',
            );
        }

        return $ret;
    }

    private function loadDefaultConfigUpload()
    {
        $props = array('upload_path', 'allowed_types', 'overwrite', 'encrypt_name', 'max_size', 'max_width', 'max_height', 'max_filename');
        $ret = array();

        foreach($props as $key){
            $ret[$key] = $this->config->item($key);
        }

        return $ret;
    }

    protected function makeDirectioryIfNotExists($dirName, $recursive = TRUE)
    {
        if(file_exists($dirName)){
            return FALSE;
        }

        return mkdir($dirName, 0777, $recursive); // bool
    }
}
```

# 仕組み

TinyMCEでファイルをアップロードすると、
FormDataにファイルとCSRFトークン値をセットして、
サーバーサイド(`/path/to/your/api/upload_pdf`)にPOSTします。

PDFファイルは `$_FILES['body_pdf']` で取り出せるので、
それをサーバーの所定ディレクトリにアップロードし、
成功したらファイル名とか成功メッセージとかをJSONレスポンスでフロントエンド(JavaScript)に返します。

フロントエンドはレスを受け取ったら、コールバック関数を用いてhrefとかtitle属性とかに値をつめつめして作業終了って感じです。

# ファイルアップロードの方法

デモを見ればわかることですが、
万が一CDNがリンク切れした時のために画像でも残します。

① 本文エディタの「リンク」ボタンをクリックします

![2022-03-14_152827.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130352/e0b55527-456e-73b6-4855-f35d688c45de.png)

② 「リンク先URL」入力欄の右にあるボタンをクリックします

![2022-03-14_152848.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130352/fedcf863-83d2-2f05-72e3-d540b0455506.png)

③ アップロードしたいPDFファイルを選択し、「開く」をクリックします

![2022-03-14_153341.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130352/843e6516-70a2-5bb1-b1a2-9b0dfda474a5.png)

④ リンクの各種値が入ったことを確認し、保存ボタンをクリックします

![2022-03-14_153446.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130352/b38a46fe-7506-c407-3a54-47383e9ead6a.png)

⑤ サーバー内のPDFファイルへのテキストリンクが生成されます。

# 参考URL

https://www.tiny.cloud/docs/configure/file-image-upload/#exampleusingfile_picker_callback

https://blog.csdn.net/Gabriel_wei/article/details/105603549