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)
    {
        $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)
    {
        $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);
            // 获取图片扩展名
            $extension = $this->guessImageTypeFromUrl($url);
            curl_close($ch);

            $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>
        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',
            link_default_target: '_blank',
            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'},
            ],
            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'} },
            ],
            paste_data_images: true,
            min_height: 400,
            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>/, '<h2 style="font-size:20px;">').replace(/<h3>/, '<h3 style="font-size:18px;">');
                // Find all img tags
                let imgTags = tempDiv.getElementsByTagName('img');
                for (let i = 0; i < imgTags.length; i++) {
                    let src = imgTags[i].getAttribute('src'); // Get the src attribute
                    imgTags[i].setAttribute('src', src); // Keep only the src attribute
                    // Remove all other attributes
                    let attributes = imgTags[i].attributes;
                    for (let j = attributes.length - 1; j >= 0; j--) {
                        let attributeName = attributes[j].name;
                        if (attributeName !== 'src') {
                            imgTags[i].removeAttribute(attributeName);
                        }
                    }
                }
                args.content = tempDiv.innerHTML;
                // 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。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!