--- tags: PHP, Laravel, Backend, TinyMCE --- # Laravel 使用 TinyMCE 以及處理上傳圖片(驗證,防呆,刪除) ## 版本 ``` Laravel 8 TinyMCE 6 ``` ## 初始化以及引入TinyMCE ### 1. 創建新項目 ```shell= composer create-project laravel/laravel my-tiny-app ``` ### 2. 到項目根目錄 ```shell= cd my-tiny-app ``` ### 3. 新增可重用組件`component` ```shell= php artisan make:component Head/tinymceConfig ``` 創建好後並編輯,初始化tiny `tinymce-config.blade.php` ## 使用API建立tinymce `no-api-key`替換成你的api key,到[Tiny](https://www.tiny.cloud/docs/tinymce/6/)註冊 ```htmlembedded= <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> ``` ## 自建tinymce ### 1. 使用 Composer 將 TinyMCE 添加到項目中: `composer require tinymce/tinymce` ### 2. 添加一個 Laravel Mix 任務,在 Mix 運行時將 TinyMCE 複製到公共文件中: 檔案: `webpack.mix.js` `mix.copyDirectory('vendor/tinymce/tinymce', 'public/js/tinymce');` ### 3. 運行 Laravel Mix 將 TinyMCE 複製到 public/js/ 目錄 `npx mix` ### 4. 新增表單的組件 ```shell= php artisan make:component Forms/tinymceEditor ``` 新增後編輯`tinymce-editor.blade.php` ```htmlembedded= <form method="post"> <textarea id="myeditorinstance">Hello, World!</textarea> </form> ``` ### 5. 編輯`welcome.blade.php` ```htmlembedded= <!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` ### 7. 語言設置 #### api方式 加上對應語言 [查看語言](https://www.tiny.cloud/get-tiny/language-packages/) ```htmlembedded= <script> tinymce.init({ // ... plugins: 'code table lists', language: "zh_TW", // ... }); </script> ``` #### 自建 將要使用語言文件解壓縮到`path/to/tinymce/langs/`文件夾中。 [下載語言包](https://www.tiny.cloud/get-tiny/language-packages/) ```htmlembedded= <script> tinymce.init({ // ... plugins: 'code table lists', language: "zh_TW", // ... }); </script> ``` ## 關閉高級升級促銷 升級促銷出現在 `TinyMCE` 菜單欄未使用的角落。如果菜單欄被禁用,它就不會出現。 ```htmlembedded= tinymce.init({ selector: "textarea", // change this value according to your HTML promotion: true }); ``` ## tiny上傳圖片 ### 1. 修改`tinymce-config.blade.php` 增加上傳圖片的`Url` ```javascript= 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.撰寫上傳控制器程式碼 ```php= 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` 訪問,運行 ```shell= php artisan storage:link ``` ### 4. 忽略csrf驗證 更改`VerifyCsrfToken.php` ```php= protected $except = [ '/upload/image', ]; ``` ### 5. 效果 ![](https://i.imgur.com/hjJ4AVh.gif) ### 範例Tiny設定 ```javascript= 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](https://www.tiny.cloud/docs/tinymce/6/) ## 處理圖片驗證 ### 改寫原先`images_upload_handler`函式 將[默認 `JavaScript`](https://www.tiny.cloud/docs/tinymce/6/file-image-upload/#images_upload_handler) 上傳處理程序函式替換為自定義邏輯的函式 #### XMLHttpRequest寫法 改寫原先官方寫法 ```htmlmixed= <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記得引入 ```htmlmixed= <!-- 如未引入才需要 --> <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 對上傳的圖片進行驗證 ```php= 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"]); } ``` ### 效果 ![](https://i.imgur.com/0ih2tSV.gif) ## 僅在表單提交時才將圖像上傳到服務器 ### 錯誤訊息`alert` 有錯誤時回報給使用者 [Tinymce 通知](https://www.tiny.cloud/docs/advanced/creating-custom-notifications/#interactiveexample) ```javascript= function createErrorNotification(name) { tinymce.activeEditor.notificationManager.open({ text: `圖片名稱:${name} 檔案過大,請重新上傳`, type: 'error' }); } ``` ### 監聽使用者點擊送出表單,成功則送出,失敗則回傳錯誤訊息 當使用者點擊送出,先取消送出動作,檢視圖片狀態,當有錯誤回報給使用者 效果如下: ![](https://i.imgur.com/5hkhrEy.gif) `tiny`設定加上`automatic_uploads: false`,不自動上傳圖片,當呼叫`editor.uploadImages()`才進行上傳動作 ```javascript= // ... 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`鍵 ```javascript= // ... 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('刪除失敗'); }); } } } }); }, ``` #### 刪除圖片程式碼 記得去新增路由 ```php= 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' => '刪除成功']); } ``` #### 效果 ![](https://i.imgur.com/YNu6Rqt.gif) ### 完整`tinymce-config.blade.php` 下台一鞠躬 ```htmlmixed= <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`,如果是本地圖片就刪除 ```php= 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); } } ```