---
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. 效果

### 範例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"]);
}
```
### 效果

## 僅在表單提交時才將圖像上傳到服務器
### 錯誤訊息`alert`
有錯誤時回報給使用者
[Tinymce 通知](https://www.tiny.cloud/docs/advanced/creating-custom-notifications/#interactiveexample)
```javascript=
function createErrorNotification(name) {
tinymce.activeEditor.notificationManager.open({
text: `圖片名稱:${name} 檔案過大,請重新上傳`,
type: 'error'
});
}
```
### 監聽使用者點擊送出表單,成功則送出,失敗則回傳錯誤訊息
當使用者點擊送出,先取消送出動作,檢視圖片狀態,當有錯誤回報給使用者
效果如下:

`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' => '刪除成功']);
}
```
#### 效果

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