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.phpMediaUploadHandler.php 和图片处理文件差不多

5、filesystems 文件

<?php

return [
    //    没有用storage目录,改到public目录下了
    'disks' => [
        'public' => [
            'driver' => 'local',
            'root' => public_path(),
            'url' => env('APP_URL'),
            'visibility' => 'public',
            'throw' => false,
        ],
    ],

];

Tinymce7富文本编辑器配置,微信公众号文章等复制实现图片自动上传

进行了一些基本配置和简化,把常用的放出来了,图片支持通过整体复制自动粘贴图片到对应位置,然后格式本身 TinyMCE 就默认有一个过滤,目前试了一下微信公众号、网易新闻、搜狐新闻、虎嗅、36 氪、凤凰网、今日头条,这些网站的图片都可以自动粘贴,其他的如果有的话,注意看控制器里面的 url 是不是有特殊处理,不过有的网站似乎图片扩展名不好获取,默认给的 png 格式,这种也能显示,就是格式有修改,比如直接放到 PS 里面是没法读取的,需要另存为才行

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。