---
# System prepended metadata

title: Laravel 使用 TinyMCE 以及處理上傳圖片(驗證，防呆，刪除)
tags: [PHP, ' Backend', ' TinyMCE', ' Laravel']

---

---
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);
    }
}
```