Laravel 使用 TinyMCE 以及處理上傳圖片(驗證,防呆)

Laravel框架
417
0
0
2022-10-03

Laravel 使用 TinyMCE 以及可上傳圖片

版本

Laravel 8

TinyMCE 6

初始化以及引入TinyMCE

1. 創建新項目

composer create-project laravel/laravel my-tiny-app

2. 到項目根目錄

cd my-tiny-app

3. 新增可重用組件component

php artisan make:component Head/tinymceConfig

創建好後並編輯,初始化tiny

tinymce-config.blade.php

no-api-key替換成你的api key,到Tiny註冊

<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
<script>
  tinymce.init({
    selector: 'textarea#myeditorinstance', // Replace this CSS selector to match the placeholder element for TinyMCE 
    plugins: 'code table lists',
    toolbar: 'undo redo | blocks | bold italic | alignleft aligncenter alignright | indent outdent | bullist numlist | code | table'
  });
</script>

4. 新增表單的組件

php artisan make:component Forms/tinymceEditor

新增後編輯tinymce-editor.blade.php

<form method="post"> 
  <textarea id="myeditorinstance">Hello, World!</textarea>
</form>

5. 編輯welcome.blade.php

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> 
  <head> 
    <meta charset="UTF-8"> 
    <meta name="viewport" content="width=device-width, initial-scale=1"> 
    <title>TinyMCE in Laravel</title> 
    <!-- Insert the blade containing the TinyMCE configuration and source script --> 
    <x-head.tinymce-config/> 
  </head> 
  <body> 
    <h1>TinyMCE in Laravel</h1> 
    <!-- Insert the blade containing the TinyMCE placeholder HTML element --> 
    <x-forms.tinymce-editor/> 
  </body>
</html>

6. 啟動服務

在本地運行就可以看到編輯器了

php artisan serve

tiny上傳圖片

1. 修改tinymce-config.blade.php

增加上傳圖片的Url

  tinymce.init({
    selector: 'textarea#myeditorinstance', // Replace this CSS selector to match the placeholder element for TinyMCE
    plugins: 'code table lists',
    toolbar: 'undo redo | blocks | bold italic | alignleft aligncenter alignright | indent outdent | bullist numlist | code | table'
    images_upload_url: "/upload/image",
  });

2.撰寫上傳控制器程式碼

public function upload(Request $request)
{
    $fileName = date("mdY") . $request->file('file')->getClientOriginalName();
    $path = $request->file('file')->storeAs('uploads', $fileName, 'public');
    return response()->json(['location' => "/storage/$path"]);
}

3. 開啟storage的web連結

public磁盤使用local驅動程序並將其文件存儲在storage/app/public.

要使這些文件可以從 Web 訪問,運行

php artisan storage:link

4. 忽略csrf驗證

更改VerifyCsrfToken.php

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

5. 效果


alt="" />

範例Tiny設定

  tinymce.init({
    selector: 'textarea#myeditorinstance', // Replace this CSS selector to match the placeholder element for TinyMCE
    plugins:
          "a11ychecker advcode casechange export emoticons formatpainter image editimage linkchecker autolink lists checklist media mediaembed pageembed permanentpen powerpaste preview table advtable tableofcontents tinycomments tinymcespellchecker",
        toolbar:
          "image preview table media" +
          "undo redo | styles | bold italic | " +
          "alignleft aligncenter alignright alignjustify | " +
          "outdent indent | numlist bullist | emoticons", // 工具欄
        toolbar_mode: "floating",
        tinycomments_mode: "embedded",
        tinycomments_author: "Author name",
        language: "zh_TW", // 介面語言
        mobile: {
          menubar: true, 
        },
        image_title: true,
        file_picker_types: 'image',
        images_upload_url: "/upload/image",
        relative_urls: false, // 更改後編輯頁面才看的到圖片
        // images_upload_base_path: "/",
  });

更多設定

TinyMCE 6 Documentation

處理圖片驗證

改寫原先images_upload_handler函式

默認 JavaScript 上傳處理程序函式替換為自定義邏輯的函式

XMLHttpRequest寫法

改寫原先官方寫法

<script> 
  const my_image_upload_handler = (blobInfo, progress) => new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.withCredentials = false;
    xhr.open('POST', '/upload/image');

    xhr.upload.onprogress = (e) => {
      progress(e.loaded / e.total * 100);
    };

    xhr.onload = () => {
      if (xhr.status === 403) {
        reject({ message: 'HTTP Error: ' + xhr.status, remove: true });
        return;
      }

      if (xhr.status < 200 || xhr.status >= 300) {
        reject('HTTP Error: ' + xhr.status);
        return;
      }

      const json = JSON.parse(xhr.responseText);
      // 增加此行 
      if (json.error) {
        reject(json.error.join('\n'));
        return;
      }

      if (!json || typeof json.location != 'string') {
        reject('Invalid JSON: ' + xhr.responseText);
        return;
      }

      resolve(json.location);
    };

    xhr.onerror = () => {
      reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
    };

    const formData = new FormData();
    formData.append('file', blobInfo.blob(), blobInfo.filename());

    xhr.send(formData);
  });
  tinymce.init({
    selector: 'textarea#myeditorinstance', // Replace this CSS selector to match the placeholder element for TinyMCE 
    images_upload_handler: my_image_upload_handler,
    plugins:
          "a11ychecker advcode casechange export emoticons formatpainter image editimage linkchecker autolink lists checklist media mediaembed pageembed permanentpen powerpaste preview table advtable tableofcontents tinycomments tinymcespellchecker",
        toolbar:
          "image preview table media" +
          "undo redo | styles | bold italic | " +
          "alignleft aligncenter alignright alignjustify | " +
          "outdent indent | numlist bullist | emoticons",
        toolbar_mode: "floating",
        tinycomments_mode: "embedded",
        tinycomments_author: "Author name",
        language: "zh_TW",
        mobile: {
          menubar: true,
        },
        image_title: true,
        file_picker_types: 'image',
        images_upload_url: "/upload/image",
        relative_urls: false,
  });
</script>

axios寫法

如沒有引入axios cdn記得引入

<!-- 如未引入才需要 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.26.1/axios.min.js" integrity="sha512-bPh3uwgU5qEMipS/VOmRqynnMXGGSRv+72H/N260MQeXZIK4PG48401Bsby9Nq5P5fz7hy5UGNmC/W1Z51h2GQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

<script>
const my_image_upload_handler = (blobInfo, progress) => new Promise((resolve, reject) => {
    const formData = new FormData();
    formData.append('file', blobInfo.blob(), blobInfo.filename());

    axios.post('/upload/image', formData)
         .then(function (response) {
          if (response.status === 403) {
            reject({ message: 'HTTP Error: ' + response.status, remove: true });
            return;
          }

          if (response.status < 200 || response.status >= 300) {
            reject('HTTP Error: ' + response.status);
            return;
          }

          if (response.data.error) {
            reject(response.data.error.join('\n'));
            return;
          }

          if (!response.data || typeof response.data.location != 'string') {
            reject('Invalid JSON: ' + xhr.responseText);
            return;
          }

          resolve(response.data.location);
        })
        .catch(function (error) {
          reject('Image upload failed due to a XHR Transport error. Error: ' + error);
        });
});
tinymce.init({
    selector: 'textarea#myeditorinstance', // Replace this CSS selector to match the placeholder element for TinyMCE 
    images_upload_handler: my_image_upload_handler,
    // .......
});
</script>

改寫上傳圖片controller

對上傳的圖片進行驗證

use Illuminate\Support\Facades\Validator;

public function upload(Request $request)
{
    $messages = [
        'file.max' => '圖片檔案不能大於2000KB',
    ];
    $validator = Validator::make($request->all(), [
        'file' => 'max:2000',
    ], $messages);

    if ($validator->fails()) {
        return response()->json([
            'error' => $validator->errors()->all(),
        ], 200, [], JSON_UNESCAPED_UNICODE);
    }

    $fileName = date("mdY") . $request->file('file')->getClientOriginalName();
    $path = $request->file('file')->storeAs('uploads', $fileName, 'public');
    return response()->json(['location' => "/storage/$path"]);
}

效果


alt="" />

僅在表單提交時才將圖像上傳到服務器

錯誤訊息alert

有錯誤時回報給使用者

Tinymce 通知

function createErrorNotification(name) {
    tinymce.activeEditor.notificationManager.open({
      text: `圖片名稱:${name} 檔案過大,請重新上傳`,
      type: 'error'
    });
}

監聽使用者點擊送出表單,成功則送出,失敗則回傳錯誤訊息

當使用者點擊送出,先取消送出動作,檢視圖片狀態,當有錯誤回報給使用者

效果如下:


alt="" />

tiny設定加上automatic_uploads: false,不自動上傳圖片,當呼叫editor.uploadImages()才進行上傳動作

// ...
setup(editor) {
  editor.on('submit', function(e) {
    e.preventDefault(); // 取消送出動作
    editor.uploadImages()
      .then(function(blobInfo, progress) {
        $status = blobInfo.map(el => {
          if (el.status == false) {
            createErrorNotification(el.blobInfo.filename());
          }
          return el.status;
        })
        if (!$status.includes(false)) {
          $('#your_form_id').submit(); // 送出表單的ID
        }
      })
      .catch(function(error) {
        console.log(error);
      });
  });
},

監聽使用者利用按鍵刪除圖片

假如有A跟B兩張圖片,A圖片符合規則,B圖片不符合,但後臺這時已經儲存A圖片,B圖片不儲存並回傳錯訊息,

如果使用者將A圖片刪除不使用,A圖片就被困在我們的資料夾裡,

為了解決這情況,就利用監聽鍵盤按鈕來觸發刪除圖片事件

首先監聽Backspace以及Delete

// ...
setup(editor) {
  editor.on("keydown", function(e){
    if ((e.keyCode == 8 || e.keyCode == 46) && tinymce.activeEditor.selection) {
      let selectedNode = tinymce.activeEditor.selection.getNode();
      if (selectedNode && selectedNode.nodeName == 'IMG') {
          let imageSrc = selectedNode.src;
          if (imageSrc.split("storage")[1]) {
              let imageName = imageSrc.split("storage")[1];
              axios.post('/delete/post/image', {
                fileName: imageName
              })
                .then(function (response) {
                    if (response.status === 200) {
                        console.log('刪除成功');
                    }
                })
                .catch(function (error) {
                    console.log('刪除失敗');
                });
          }
      }
    }
  });
},

刪除圖片程式碼

記得去新增路由

use Illuminate\Support\Facades\Storage;

public function deleteUpload(Request $request)
{
    $fileName = $request->fileName;
    if (Storage::disk('public')->exists($fileName)) {
        Storage::disk('public')->delete($fileName);
    }
    return response()->json(['success' => '刪除成功']);
}

效果


alt="" />

完整tinymce-config.blade.php

下台一鞠躬

<script src="https://cdn.tiny.cloud/1/nsoqryhl188m8ah7y44z7ln6aj2dujg7aoyc4bijnv04nsqj/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
{{-- <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.26.1/axios.min.js" integrity="sha512-bPh3uwgU5qEMipS/VOmRqynnMXGGSRv+72H/N260MQeXZIK4PG48401Bsby9Nq5P5fz7hy5UGNmC/W1Z51h2GQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> --}}
<script> 
  const my_image_upload_handler = (blobInfo, progress) => new Promise((resolve, reject) => {
    const formData = new FormData();
    formData.append('file', blobInfo.blob(), blobInfo.filename());
    axios.post('/upload/image', formData)
      .then(function (response) {
        if (response.status === 403) {
          reject({ message: 'HTTP Error: ' + response.status, remove: true });
          return;
        }
        if (response.status < 200 || response.status >= 300) {
          reject('HTTP Error: ' + response.status);
          return;
        }
        if (response.data.error) {
          reject(response.data.error.join('\n'));
          return;
        }
        if (!response.data || typeof response.data.location != 'string') {
          reject('Invalid JSON: ' + xhr.responseText);
          return;
        }
        resolve(response.data.location);
      })
      .catch(function (error) {
        reject('Image upload failed due to a XHR Transport error. Error: ' + error);
      });
  });
  function createErrorNotification(name) {
    tinymce.activeEditor.notificationManager.open({
      text: `圖片名稱:${name} 檔案過大,請重新上傳`,
      type: 'error'
    });
  }
  tinymce.init({
    selector: 'textarea#myeditorinstance', // Replace this CSS selector to match the placeholder element for TinyMCE 
    images_upload_handler: my_image_upload_handler,
    plugins:
          "a11ychecker advcode casechange export emoticons formatpainter image editimage linkchecker autolink lists checklist media mediaembed pageembed permanentpen powerpaste preview table advtable tableofcontents tinycomments tinymcespellchecker",
        toolbar:
          "image preview table media" +
          "undo redo | styles | bold italic | " +
          "alignleft aligncenter alignright alignjustify | " +
          "outdent indent | numlist bullist | emoticons",
        toolbar_mode: "floating",
        tinycomments_mode: "embedded",
        tinycomments_author: "Author name",
        language: "zh_TW",
        mobile: {
          menubar: true,
        },

        file_picker_types: 'image',
        relative_urls: false,
        image_title: true,
        automatic_uploads: false,
        images_upload_url: "/upload/image",
        setup(editor) {
          editor.on("keydown", function(e){
            if ((e.keyCode == 8 || e.keyCode == 46) && tinymce.activeEditor.selection) {
              let selectedNode = tinymce.activeEditor.selection.getNode();
              if (selectedNode && selectedNode.nodeName == 'IMG') {
                  let imageSrc = selectedNode.src;
                  if (imageSrc.split("storage")[1]) {
                      let imageName = imageSrc.split("storage")[1];
                      axios.post('/delete/image', {
                        fileName: imageName
                      })
                        .then(function (response) {
                            if (response.status === 200) {
                                console.log('刪除成功');
                            }
                        })
                        .catch(function (error) {
                            console.log('刪除失敗');
                        });
                  }
              }
            }
          });
          editor.on('submit', function(e) {
            e.preventDefault(); // 取消送出動作
            editor.uploadImages()
              .then(function(blobInfo, progress) {
                $status = blobInfo.map(el => {
                  if (el.status == false) {
                    createErrorNotification(el.blobInfo.filename()); // 錯誤訊息alert
                  }
                  return el.status;
                })
                if (!$status.includes(false)) {
                  $('#your_form_id').submit(); // 你的表單ID
                }
              })
              .catch(function(error) {
                console.log(error);
              });
          });
      },
  });
</script>

刪除時連帶刪除圖片

利用正規表達式找到img tag,如果是本地圖片就刪除

public function destroy(Request $request, $id)
{
    $post = Post::findOrFail(id);
    preg_match_all('/<img [^>]*src="[^"]*"[^>]*>/', $post->content, $matches);
    foreach ($matches[0] as $match) {
        preg_match('/.*src="([^"]*)".*/', $match, $match);
        $url = explode("storage", $match[1]);
        if (count($url) > 1) {
            $this->delPic($url[1]);
        }
    }
    $post->delete();
    return response()->json(['success' => '文章刪除成功']);
}

public function delPic($fileName)
{
    if (Storage::disk('public')->exists($fileName)) {
        Storage::disk('public')->delete($fileName);
    }
}