Tinymce7富文本编辑器配置,微信公众号文章等一些网站复制图文,实现图片自动上传
一直想找一款简洁又优雅的富文本编辑器,之前用过百度ueditor,wangEditor,忽然发现了tinymce编辑器,有一个网站是基于它做的,各方面体验不错,然后我就参考研究了一下,也参考了本站的文章Laravel 使用 TinyMCE 以及處理上傳圖片(驗證,防呆)
话不多说,贴出代码
1、控制器
<?php
namespace App\Http\Controllers;
use App\Handlers\FileUploadHandler;
use App\Handlers\ImageUploadHandler;
use App\Handlers\MediaUploadHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class DemoController extends Controller
{
public function uploadImage(Request $request, ImageUploadHandler $uploader)
{
$validator = Validator::make($request->all(), [
'file' => 'mimes:png,jpg,gif,jpeg,webp|max:5120',
], [
'file.mimes' => '目前只支持png/jpg/gif/jpeg/webp格式图片',
'file.max' => '图片大小不能超过5M',
]);
if($validator->fails()) {
return response()->json([
'error' => $validator->errors()->all(),
], 200, [], JSON_UNESCAPED_UNICODE);
}
$data = [];
$file = $request->file('file');
$result = $uploader->save($file, 'articles', 1080);
if($result) {
$data['location'] = $result['path'];
}
return response()->json($data);
}
public function uploadFile(Request $request, FileUploadHandler $uploader)
{
$validator = Validator::make($request->all(), [
'file' => 'mimes:pdf,txt,zip,rar,7z,doc,docx,xls,xlsx,ppt,pptx|max:10240',
], [
'file.mimes' => '目前只支持pdf/txt/zip/rar/7z/doc/docx/xls/xlsx/ppt/pptx格式附件',
'file.max' => '附件大小不能超过10M',
]);
if($validator->fails()) {
return response()->json([
'error' => $validator->errors()->all(),
], 200, [], JSON_UNESCAPED_UNICODE);
}
$data = [];
$file = $request->file('file');
$result = $uploader->save($file, 'articles');
if($result) {
$data['location'] = $result['path'];
}
return response()->json($data);
}
public function uploadMedia(Request $request, MediaUploadHandler $uploader)
{
$validator = Validator::make($request->all(), [
'file' => 'mimes:mp3,mp4|max:51200',
], [
'file.mimes' => '目前只支持mp3/mp4格式媒体文件',
'file.max' => '媒体文件大小不能超过50M',
]);
if($validator->fails()) {
return response()->json([
'error' => $validator->errors()->all(),
], 200, [], JSON_UNESCAPED_UNICODE);
}
$data = [];
$file = $request->file('file');
$result = $uploader->save($file, 'articles');
if($result) {
$data['location'] = $result['path'];
}
return response()->json($data);
}
public function deleteUpload(Request $request)
{
$fileName = $request->input('fileName');
if(Storage::disk('public')->exists($fileName)) {
Storage::disk('public')->delete($fileName);
}
return response()->json(['success' => '删除成功']);
}
public function pasteImage(Request $request)
{
$data = json_decode(file_get_contents('php://input'), true);
$imageUrls = $data['urls'];
$uploadedUrls = [];
foreach ($imageUrls as $url) {
// 处理图片链接的html实体字符,比如网易新闻的图片链接
$url = htmlspecialchars_decode($url);
// 使用curl获取图像内容
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, 0);
$imageData = curl_exec($ch);
curl_close($ch);
// 获取图片扩展名
$extension = $this->guessImageTypeFromUrl($url);
$folder_name = "uploads/images/articles/" . date("Y/m/d", time());
// 检查文件夹是否存在
if(!file_exists($folder_name)) {
mkdir($folder_name, 0755, true);
}
$upload_path = public_path() .'/'. $folder_name;
$fileName = time() . '_' . Str::random(10) . '.' . $extension;
// 保存图片到本地
file_put_contents($upload_path .'/'. $fileName, $imageData);
// 保存图片url到数组
$uploadedUrls[] = config('app.url')."/$folder_name/$fileName";
}
// 返回上传的所有图片url
return json_encode(['locations' => $uploadedUrls]);
}
public function guessImageTypeFromUrl($url) {
// 尝试从URL中提取文件名部分
$parsedUrl = parse_url($url);
$path = $parsedUrl['path'] ?? '';
$filenameParts = explode('/', $path);
$filename = end($filenameParts);
// 检查文件名中是否包含明确的扩展名
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!empty($extension) && in_array($extension, ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'svg'])) {
return $extension;
}
// 检查URL的查询字符串中是否有关于图片类型的线索
parse_str($parsedUrl['query'] ?? '', $queryParams);
foreach ($queryParams as $k => $v) {
switch ($k) {
case 'wx_fmt': // 微信公众号文章
case 'type': // 网易新闻
return strtolower($v);
}
}
// 如果没有找到明确的类型,返回一个默认值
return 'png';
}
}
2、视图文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="{{ asset('tinymce/tinymce.min.js') }}" referrerpolicy="origin"></script>
<script>
const removeTagAttribute = (tags, tagName) => {
// 找到所有标签的属性
for (let i = 0; i < tags.length; i++) {
if(tagName === 'img') {
let src = tags[i].getAttribute('src');
tags[i].setAttribute('src', src);
}
// 移除所有标签的属性
let attributes = tags[i].attributes;
for (let j = attributes.length - 1; j >= 0; j--) {
let attributeName = attributes[j].name;
if(tagName === 'img') {
if (attributeName !== 'src') {
tags[i].removeAttribute(attributeName);
}
}else {
tags[i].removeAttribute(attributeName);
}
}
if(tagName === 'img') {
// 图片自动居中
tags[i].setAttribute('style', 'display: block;margin-left: auto;margin-right: auto;');
}
}
},
tinymce.init({
selector: '#content',
body_class: 'content',
content_css: 'tinymce/content.css',
license_key: 'gpl',
menubar: false,
statusbar: false,
toolbar_sticky: true,
plugins: 'image media link lists table autoresize wordcount autolink',
toolbar: 'undo redo fontsize styles image bold italic underline alignleft aligncenter alignright bullist numlist media link table blockquote',
font_size_formats: '12px 16px 18px 20px',
style_formats: [
{ title: '一级标题', block: 'h2', styles: {'font-size':'20px'} },
{ title: '二级标题', block: 'h3', styles: {'font-size':'18px'} },
{ title: '正文', block: 'p', styles: {'font-size':'16px'} },
{ title: '标注', block: 'p', styles: {'font-size':'12px', color:'#888888'} },
],
link_default_target: '_blank',
link_title: false,
pad_empty_with_br: true,
table_toolbar: 'tableprops tablerowprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tabledelete',
table_appearance_options: false,
table_advtab: false,
table_row_advtab: false,
table_cell_advtab: false,
table_row_class_list: [
{title: '无', value: ''},
{title: '深底白字', value: 'table-dark-row'},
{title: '浅底黑字', value: 'table-light-row'},
{title: '浅底白字', value: 'table-light-white-row'},
],
paste_data_images: true,
entity_encoding: 'raw',
invalid_elements: 'strong,span,em',
min_height: 600,
language: 'zh_CN',
language_url: 'tinymce/lang/zh_CN.js',
paste_preprocess: (editor, args) => {
let tempDiv = document.createElement('div');
tempDiv.innerHTML = args.content.replace(/<section|<h1|<h4|<h5|<h6/g, "<p").replace(/<\/section>|<\/h1>|<\/h4>|<\/h5>|<\/h6>/g, "</p>").replace(/<h2>/g, '<h2 style="font-size:20px;">').replace(/<h3>/g, '<h3 style="font-size:18px;">');
// 找到所有img标签,只保留src属性
let imgTags = tempDiv.getElementsByTagName('img');
removeTagAttribute(imgTags, 'img');
// 找到所有p标签,移除所有属性
let pTags = tempDiv.getElementsByTagName('p');
removeTagAttribute(pTags, 'p');
// 删除p多余空标签
args.content = tempDiv.innerHTML.replace(/<p><\/p>/g, '');
// Extract image URLs from pasted content
let imageUrls = [];
let imgRegex = /<img[^>]+src="(https?:\/\/[^">]+)"/g;
let match;
while (match = imgRegex.exec(args.content)) {
imageUrls.push(match[1]);
}
// Process each image URL (could also send all URLs to server at once)
if (imageUrls.length > 0) {
fetch('{{ route('editor.paste_image') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ urls: imageUrls })
}).then(response => {
return response.json();
}).then(data => {
if (data && data.locations) {
let currentContent = editor.getContent();
data.locations.forEach((location, index) => {
currentContent = currentContent.replace(imageUrls[index], location);
});
editor.setContent(currentContent);
console.log('Replace content:', currentContent);
}
}).catch(error => {
console.error('Error uploading images:', error);
});
}
console.log('Original content:', args.content);
},
images_upload_url: '{{ route('editor.upload_image') }}',
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', '{{ route('editor.upload_image') }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
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);
}),
file_picker_callback: function (callback, value, meta) {
//文件分类
let filetype = '.pdf, .txt, .zip, .rar, .7z, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .mp3, .mp4, .MOV';
//后端接收上传文件的地址
let upUrl = '{{ route('editor.upload_file') }}';
//为不同插件指定文件类型及后端地址
switch (meta.filetype) {
case 'image':
filetype = '.jpg, .jpeg, .png, .gif, .webp';
upUrl = '{{ route('editor.upload_image') }}';
break;
case 'media':
filetype = '.mp3, .mp4';
upUrl = '{{ route('editor.upload_media') }}';
break;
case 'file':
default:
}
//模拟出一个input用于添加本地文件
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', filetype);
input.click();
input.onchange = function () {
const file = this.files[0];
let xhr, formData;
console.log(file.name);
xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', upUrl);
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}')
xhr.onload = function () {
if (xhr.status === 403) {
alert({ message: 'HTTP Error: ' + xhr.status, remove: true });
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
alert('HTTP Error: ' + xhr.status);
return;
}
const json = JSON.parse(xhr.responseText);
if (json.error) {
alert(json.error.join('\n'));
return;
}
if (!json || typeof json.location != 'string') {
alert('Invalid JSON: ' + xhr.responseText);
return;
}
callback(json.location);
};
xhr.onerror = () => {
alert('File upload failed due to a XHR Transport error. Code: ' + xhr.status);
};
formData = new FormData();
formData.append('file', file, file.name);
xhr.send(formData);
}
},
setup: function(editor) {
editor.on("keydown", function(e){
if ((e.keyCode === 8 || e.keyCode === 46) && tinymce.activeEditor.selection) {
let selectedNode = tinymce.activeEditor.selection.getNode();
console.log(selectedNode);
if (selectedNode) {
let path = '';
switch (selectedNode.nodeName) {
case 'IMG':
path = selectedNode.src; // 图片文件
break;
case 'A':
path = selectedNode.href; // 链接文件
break;
default:
let child = selectedNode.children[0];
if(child) {
if(child.nodeName === 'AUDIO') { // 音频文件
path = child.src;
}else if(child.nodeName === 'VIDEO') { // 视频文件
if(child.children.length > 0 && child.children[0].nodeName === 'SOURCE') {
path = child.children[0].src;
}else {
path = child.src;
}
}
}
break;
}
console.log(path);
path = path.replace('{{ config('app.url') }}', '');
if(path) {
axios.delete('{{ route('editor.delete_upload') }}', { params: {
fileName: path
}}).then(function (response) {
if (response.status === 200) {
console.log('删除成功');
}
}).catch(function (error) {
console.log('删除失败');
});
}
}
}
});
},
});
</script>
@vite('resources/js/app.js')
</head>
<body>
<h1>TinyMCE Quick Start Guide</h1>
<form method="post" action="{{ route('editor.store') }}" onsubmit="return false;">
{{ csrf_field() }}
<label for="content"></label><textarea id="content" class="content">Hello, World!</textarea>
</form>
</body>
</html>
3、路由文件
<?php
use App\Http\Controllers\AuthController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\DemoController;
use Illuminate\Routing\Router;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
|
*/
Route::get('editor', [DemoController::class, 'editor'])->name('editor');
Route::post('editor/store', [DemoController::class, 'store'])->name('editor.store');
Route::post('editor/upload_image', [DemoController::class, 'uploadImage'])->name('editor.upload_image');
Route::post('editor/upload_file', [DemoController::class, 'uploadFile'])->name('editor.upload_file');
Route::post('editor/upload_media', [DemoController::class, 'uploadMedia'])->name('editor.upload_media');
Route::delete('editor/delete_upload', [DemoController::class, 'deleteUpload'])->name('editor.delete_upload');
Route::post('editor/paste_image', [DemoController::class, 'pasteImage'])->name('editor.paste_image');
4、图片处理器
<?php
namespace App\Handlers;
use Illuminate\Support\Str;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
class ImageUploadHandler
{
public function save($file, $folder, $max_width = false)
{
// 构建存储的文件夹规则,值如:uploads/images/articles/2024/07/02/
// 文件夹切割能让查找效率更高。
$folder_name = "uploads/images/$folder/" . date("Y/m/d", time());
// 文件具体存储的物理路径,`public_path()` 获取的是 `public` 文件夹的物理路径。
// 值如:/home/vagrant/Code/laravel/public/uploads/images/articles/2024/07/02/
$upload_path = public_path() . '/' . $folder_name;
// 获取文件的后缀名,因图片从剪贴板里黏贴时后缀名为空,所以此处确保后缀一直存在
$extension = strtolower($file->getClientOriginalExtension()) ?: 'png';
// 拼接文件名,加前缀是为了增加辨析度,前缀可以是相关数据模型的 ID
// 值如:1_1493521050_7BVc9v9ujP.png
$filename = time() . '_' . Str::random(10) . '.' . $extension;
// 将图片移动到我们的目标存储路径中
$file->move($upload_path, $filename);
// 如果限制了图片宽度,就进行裁剪
if ($max_width && $extension != 'gif') {
// 此类中封装的函数,用于裁剪图片
$this->reduceSize($upload_path . '/' . $filename, $max_width);
}
return [
'path' => config('app.url') . "/$folder_name/$filename"
];
}
public function reduceSize($file_path, $max_width)
{
// 先实例化,传参是文件的磁盘物理路径
$manager = ImageManager::withDriver(Driver::class);
$image = $manager->read($file_path);
// 进行大小调整的操作,限制最大宽度
$image->scaleDown($max_width);
// 对图片修改后进行保存
$image->save();
}
}
另外两个文件,FileUploadHandler.php
、MediaUploadHandler.php
和图片处理文件差不多
5、filesystems文件
<?php
return [
// 没有用storage目录,改到public目录下了
'disks' => [
'public' => [
'driver' => 'local',
'root' => public_path(),
'url' => env('APP_URL'),
'visibility' => 'public',
'throw' => false,
],
],
];
进行了一些基本配置和简化,把常用的放出来了,图片支持通过整体复制自动粘贴图片到对应位置,然后格式本身TinyMCE就默认有一个过滤,目前试了一下微信公众号、网易新闻、搜狐新闻、虎嗅、36氪、凤凰网、今日头条,这些网站的图片都可以自动粘贴,其他的如果有的话,注意看控制器里面的url是不是有特殊处理,不过有的网站似乎图片扩展名不好获取,默认给的png格式,这种也能显示,就是格式有修改,比如直接放到PS里面是没法读取的,需要另存为才行
本作品采用《CC 协议》,转载必须注明作者和本文链接
本站的md使用啥做的?