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

使用api

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>

自建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. 新增表單的組件

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

7. 語言設置

api方式

加上對應語言

查看語言

<script>
tinymce.init({
// ...
plugins:
'code table lists',
language: "zh_TW",
// ...
});
</script>

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

範例 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"]);
}

效果

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

錯誤訊息alert

有錯誤時回報給使用者

Tinymce 通知

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

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

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

效果如下:

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

效果

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