Stoormz'log
CKEditor 5 et Symfony sans bundle - Partie 2 - Build , Filemanager et Configuration avancée

CKEditor 5 et Symfony sans bundle - Partie 2 - Build , Filemanager et Configuration avancée


 Nous avons vu dans l'article CKeditor 5 et Symfony 6 sans bundle - Partie 1 comment  faire une installation basique de CKEditor 5 sans bundle dans un projet Symfony, nous allons voir maintenant comment faire un éditeur sur mesure, tuner notre configuration, mais aussi comment ajouter un File Manager pour faciliter la gestion des images et autres fichiers. 

Pour rappel, CKeditor 5 est distribué sous licence  Open Source GNU General Public License (GPL) Version 2 ou ultérieure, il faut que ton projet soit compatible avec cette dernière, dans mon cas c'est un projet perso, sinon il faut prendre contact avec la société pour avoir une licence spécifique.

Build et configuration

Je pars du principe que vous avez un setup déjà prêt avec  Symfony / Webpack Encore.

Pour la partie tuning, on va builder l'éditeur depuis ses sources en ajoutant les plugins nécessaires.

Pour mon blog, je vais avoir besoin des plugins/fonctionnalités suivantes :

Bien sûr, tu peux en ajouter à ta guise, la liste complète des plugins est dispo par ici: https://ckeditor.com/docs/ckeditor5/latest/installation/plugins/features-html-output-overview.html 

on va installer tout ça:

$ npm install --save @ckeditor/ckeditor5-editor-classic @ckeditor/ckeditor5-source-editing @ckeditor/ckeditor5-essentials @ckeditor/ckeditor5-autoformat @ckeditor/ckeditor5-basic-styles @ckeditor/ckeditor5-block-quote @ckeditor/ckeditor5-heading @ckeditor/ckeditor5-link @ckeditor/ckeditor5-list @ckeditor/ckeditor5-paragraph @ckeditor/ckeditor5-code-block @ckeditor/ckeditor5-highlight @ckeditor/ckeditor5-ckfinder @ckeditor/ckeditor5-adapter-ckfinder @ckeditor/ckeditor5-image @ckeditor/ckeditor5-table ckeditor5-build-classic @ckeditor/ckeditor5-dev-utils @ckeditor/ckeditor5-dev-translations

et notre script d'initialisation
 

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import { Essentials } from '@ckeditor/ckeditor5-essentials/src';
import { Autoformat } from '@ckeditor/ckeditor5-autoformat/src';
import { Bold, Italic, Strikethrough } from '@ckeditor/ckeditor5-basic-styles/src';
import { BlockQuote } from '@ckeditor/ckeditor5-block-quote/src';
import { Heading } from '@ckeditor/ckeditor5-heading/src';
import { Link } from '@ckeditor/ckeditor5-link/src';
import { List } from '@ckeditor/ckeditor5-list/src';
import { Paragraph } from '@ckeditor/ckeditor5-paragraph/src';
import { CodeBlock } from '@ckeditor/ckeditor5-code-block/src';
import { Highlight } from '@ckeditor/ckeditor5-highlight/src';
import { CKFinder } from '@ckeditor/ckeditor5-ckfinder/src';
import {CKFinderUploadAdapter} from "@ckeditor/ckeditor5-adapter-ckfinder/src";
import {Image, ImageBlock, ImageUpload, ImageCaptionUI, ImageInsert} from "@ckeditor/ckeditor5-image/src";
import {Table} from "@ckeditor/ckeditor5-table/src";
import  "ckeditor5/build/translations/fr";

ClassicEditor
    .create( document.querySelector( '.ckeditor'), {
        language: {
            ui: 'fr',
          },
        plugins: [
            // Existing plugins
            Essentials,
            Autoformat,
            Bold,
            Italic,
            Strikethrough,
            BlockQuote,
            Heading,
            Link,
            List,
            Paragraph,
            Highlight,
            CodeBlock,
            CKFinderUploadAdapter,
            CKFinder,
            Image,ImageBlock, ImageUpload, ImageInsert,ImageCaptionUI,
            Table
        ],
        toolbar: [
            // Existing toolbar buttons
            'heading',
            'bold',
            'italic',
            'strikethrough',
            'link',
            'bulletedList',
            'numberedList',
            'blockQuote',
            'highlight',
            'undo',
            'redo',
            'codeBlock', 'insertImage','insertTable','table','tableRow', 'mergeTableCells'

        ],
        codeBlock: {
            languages: [
                { language: 'plaintext', label: 'Plain text' }, // The default language.
                { language: 'c', label: 'C' },
                { language: 'cs', label: 'C#' },
                { language: 'cpp', label: 'C++' },
                { language: 'css', label: 'CSS' },
                { language: 'diff', label: 'Diff' },
                { language: 'html', label: 'HTML' },
                { language: 'java', label: 'Java' },
                { language: 'javascript', label: 'JavaScript' },
                { language: 'php', label: 'PHP' },
                { language: 'python', label: 'Python' },
                { language: 'ruby', label: 'Ruby' },
                { language: 'typescript', label: 'TypeScript' },
                { language: 'xml', label: 'XML' }
            ]
        },
        table: {
            contentToolbar: [
                'tableColumn',
                'tableRow',
                'mergeTableCells'
            ]
        }
    } )
    .then( editor => {
        console.log(Array.from( editor.ui.componentFactory.names() ));
    } )
    .catch( error => {

        console.error( error );
    } );

Okay, on a notre script d'initialisation, passons maintenant à la partie Webpack:

const Encore = require('@symfony/webpack-encore');
const path = require( 'path' );
const { CKEditorTranslationsPlugin } = require( '@ckeditor/ckeditor5-dev-translations' );
const { styles } = require( '@ckeditor/ckeditor5-dev-utils' );


if (!Encore.isRuntimeEnvironmentConfigured()) {
    Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}

Encore
    .setOutputPath('public/build/')
    .setPublicPath('/build')
    .addEntry('editor_init', './assets/js/ckeditor/ckeditor_init.js') // init script

    .addEntry('app', './assets/js/app.js')
    .addEntry('article', './assets/js/article.js')
    .addEntry('comment', './assets/js/comment.js')
    .splitEntryChunks()
    .enableSingleRuntimeChunk()
    .cleanupOutputBeforeBuild()
    .enableBuildNotifications()
    .enableSourceMaps(!Encore.isProduction())
    .enableVersioning(Encore.isProduction())
    .configureBabelPresetEnv((config) => {
        config.useBuiltIns = 'usage';
        config.corejs = 3;
    })
    .enableSassLoader()
    .addPlugin( new CKEditorTranslationsPlugin( {
        // See https://ckeditor.com/docs/ckeditor5/latest/features/ui-language.html
        language: 'fr'
    } ) )
    // Use raw-loader for CKEditor 5 SVG files.
    .addRule( {
        test: /ckeditor5-[^/\\]+[/\\]theme[/\\]icons[/\\][^/\\]+\.svg$/,
        loader: 'raw-loader'
    } )

    // Configure other image loaders to exclude CKEditor 5 SVG files.
    .configureLoaderRule( 'images', loader => {
        loader.exclude = /ckeditor5-[^/\\]+[/\\]theme[/\\]icons[/\\][^/\\]+\.svg$/;
    } )

    // Configure PostCSS loader.
    .addLoader({
        test: /ckeditor5-[^/\\]+[/\\]theme[/\\].+\.css$/,
        loader: 'postcss-loader',
        options: {
            postcssOptions: styles.getPostCssConfig( {
                themeImporter: {
                    themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' )
                },
                minify: true
            } )
        }
    } )
;

module.exports = Encore.getWebpackConfig();

 

il ne reste plus qu'à attacher notre script au template twig :

//templates/article/index.html.twig
...
    
{{ encore_entry_script_tags('ckeditor_init') }}

...

Et enfin lancer le build :

$ ./node_modules/.bin/encore dev
Running webpack ...

 WARNING  Webpack is already provided by Webpack Encore, also adding it to your package.json file may cause issues.
 WARNING  Be careful when using Encore.configureLoaderRule(), this is a low-level method that can potentially break Encore and Webpack when not used carefully.
[CKEditorTranslationsPlugin] Error: Too many JS assets has been found during the compilation. You should use one of the following options to specify the strategy:
- use `addMainLanguageTranslationsToAllAssets` to add translations for the main language to all assets,- use `buildAllTranslationsToSeparateFiles` to add translation files via `<script>` tags in HTML file,- use `translationsOutputFile` to append translation to the existing file or create a new asset.For more details visit https://github.com/ckeditor/ckeditor5-dev/tree/master/packages/ckeditor5-dev-translations.
 DONE  Compiled successfully in 17584ms                                                                                                                                                                                             5:59:52 PM

26 files written to public/build
Entrypoint editor_init [big] 13.3 MiB = runtime.js 17.9 KiB vendors-node_modules_core-js_internals_create-property_js-node_modules_core-js_internals_func-16f6ea.js 208 KiB vendors-node_modules_core-js_modules_es_array_from_js-node_modules_ckeditor_ckeditor5-adapter-01d15d.css 479 KiB vendors-node_modules_core-js_modules_es_array_from_js-node_modules_ckeditor_ckeditor5-adapter-01d15d.js 12.6 MiB editor_init.js 15.7 KiB
Entrypoint editor_fr_ui 24.6 KiB = runtime.js 17.9 KiB editor_fr_ui.js 6.69 KiB
Entrypoint app [big] 2.4 MiB (987 KiB) = 7 assets 8 auxiliary assets
Entrypoint article [big] 584 KiB = 6 assets
Entrypoint comment [big] 1.09 MiB = 7 assets
webpack compiled successfully

Bon a quelques warnings, mais rien de bien méchant, on a maintenant notre éditeur de fonctionnel :

CKEDitor 5 - initialisation et debug

On remarque un message de debug dans la console, il s'agit des composants disponibles, prévenants des plugins, qu'on peut utiliser dans la toolbar.

C'est la ligne suivante dans le script d'initialisation qui en est responsable (à virer en prod of course) :

console.log(Array.from( editor.ui.componentFactory.names() ));

Dans la liste des composants

Le gestionnaire de fichiers et uploads (File manager)

Toutes mes tentatives pour installer CKFinder ont échoué, entre tweaks foireux et téléchargement direct du code source depuis une url non officielle, sans parler de toutes les problématiques côté backend...
J'ai alors jeté mon dévolu sur notre bon vieux elFinder, ce "file manager" possède les fonctionnalités nécessaires et suffisantes pour une gestion complète des fichiers.

Pour nous faciliter la tâche, on va utiliser le bundle FMElfinderBundle,  l'installation est très bien décrite sur le site du bundle : https://github.com/helios-ag/FMElfinderBundle 

Le bundle ne sera utiliser que pour la partie backend (lister les fichiers, l'upload ...)  pour la partie JS, nous avons déjà notre CKeditor v5  de prêt. Pour que ce là fonctionne, il faut tweaker un peu ;) comme expliquer par ici : https://github.com/Studio-42/elFinder/wiki/Integration-with-CKEditor-5 

Je suis arrivé à la configuration fonctionnelle suivante :

Webpack:

const Encore = require('@symfony/webpack-encore');
const { CKEditorTranslationsPlugin } = require( '@ckeditor/ckeditor5-dev-translations' );
const { styles } = require( '@ckeditor/ckeditor5-dev-utils' );

// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
    Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}

Encore
    // directory where compiled assets will be stored
    .setOutputPath('public/build/')
    // public path used by the web server to access the output path
    .setPublicPath('/build')
    // only needed for CDN's or subdirectory deploy
    //.setManifestKeyPrefix('build/')

    /*
     * ENTRY CONFIG
     *
     * Each entry will result in one JavaScript file (e.g. app.js)
     * and one CSS file (e.g. app.css) if your JavaScript imports CSS.
     */
    .addEntry('editor_init', './assets/js/ckeditor/ckeditor_init.js') // init script
    .addEntry('jquery', './node_modules/jquery/dist/jquery.min.js') // jquery
    .addEntry('jquery-ui', './node_modules/jquery-ui/dist/jquery-ui.min.js') // jquery ui

    .copyFiles({
        from: 'node_modules/@ckeditor/ckeditor5-theme-lark/',
        to: 'ckeditor5-theme-lark/[path]/[name].[ext]'
    })
    .copyFiles({
        from: 'node_modules/jquery',
        to: 'jquery/[path]/[name].[ext]'
    })
    .copyFiles({
        from: 'node_modules/jquery-ui',
        to: 'jquery-ui/[path]/[name].[ext]'
    })



    // When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
    .splitEntryChunks()

    // will require an extra script tag for runtime.js
    // but, you probably want this, unless you're building a single-page app
    .enableSingleRuntimeChunk()

    /*
     * FEATURE CONFIG
     *
     * Enable & configure other features below. For a full
     * list of features, see:
     * https://symfony.com/doc/current/frontend.html#adding-more-features
     */
    .cleanupOutputBeforeBuild()
    .enableBuildNotifications()
    .enableSourceMaps(!Encore.isProduction())
    // enables hashed filenames (e.g. app.abc123.css)
    .enableVersioning(Encore.isProduction())

    // configure Babel
    // .configureBabel((config) => {
    //     config.plugins.push('@babel/a-babel-plugin');
    // })

    // enables and configure @babel/preset-env polyfills
    .configureBabelPresetEnv((config) => {
        config.useBuiltIns = 'usage';
        config.corejs = '3.23';
    })

    // enables Sass/SCSS support
    .enableSassLoader()

    // uncomment if you use TypeScript
    .enableTypeScriptLoader()
    // Use raw-loader for CKEditor 5 SVG files.
    .addRule( {
        test: /ckeditor5-[^/\\]+[/\\]theme[/\\]icons[/\\][^/\\]+\.svg$/,
        loader: 'raw-loader'
    } )

    // Configure other image loaders to exclude CKEditor 5 SVG files.
    .configureLoaderRule( 'images', loader => {
        loader.exclude = /ckeditor5-[^/\\]+[/\\]theme[/\\]icons[/\\][^/\\]+\.svg$/;
    } )
    .addLoader({
        test: /ckeditor5-[^/\\]+[/\\]theme[/\\].+\.css$/,
        loader: 'postcss-loader',
        options: {
            postcssOptions: styles.getPostCssConfig( {
                themeImporter: {
                    themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' )
                },
                minify: true
            } )
        }
    } )
    // uncomment if you use React
    //.enableReactPreset()

    // uncomment to get integrity="..." attributes on your script & link tags
    // requires WebpackEncoreBundle 1.4 or higher
    //.enableIntegrityHashes(Encore.isProduction())

    // uncomment if you're having problems with a jQuery plugin
    .autoProvidejQuery()
;

module.exports = Encore.getWebpackConfig();

Et côté TWIG:

{% extends 'base.html.twig' %}

{% block title %}Symfony 6, CKeditor 5 and Elfinder - Demo ✅{% endblock %}

{% block body %}

    <h1>Symfony 6, CKeditor 5 and Elfinder  - Demo  ✅</h1>

    <textarea class="ckeditor"></textarea>
    <div></div>
{% endblock %}

{% block javascripts %}
    <script src="{{ asset("build/jquery/dist/jquery.min.js") }}"></script>
    <script src="{{ asset("build/jquery-ui/dist/jquery-ui.min.js") }}"></script>
    <script src="{{ asset("bundles/fmelfinder/js/elfinder.full.js") }}"></script>
    <script src="{{ asset("bundles/fmelfinder/js/i18n/elfinder.fr.js") }}"></script>
    <script defer>
        // init Elfinder stuffs
        const uploadTargetHash = 'l1_Lw';
        const connectorUrl = '/efconnect';
        document.addEventListener('DOMContentLoaded', function () {
            initEditor('.ckeditor', function (editor) {
                const ckf = editor.commands.get('ckfinder'),
                    fileRepo = editor.plugins.get('FileRepository'),
                    ntf = editor.plugins.get('Notification'),
                    i18 = editor.locale.t,
                    // Insert images to editor window
                    insertImages = urls => {
                        const imgCmd = editor.commands.get('imageUpload');
                        if (!imgCmd.isEnabled) {
                            ntf.showWarning(i18('Could not insert image at the current position.'), {
                                title: i18('Inserting image failed'),
                                namespace: 'ckfinder'
                            });
                            return;
                        }
                        editor.execute('imageInsert', {source: urls});
                    },
                    // To get elFinder instance
                    getfm = open => {
                        return new Promise((resolve, reject) => {
                            // Execute when the elFinder instance is created
                            const done = () => {
                                if (open) {
                                    // request to open folder specify
                                    if (!Object.keys(_fm.files()).length) {
                                        // when initial request
                                        _fm.one('open', () => {
                                            _fm.file(open) ? resolve(_fm) : reject(_fm, 'errFolderNotFound');
                                        });
                                    } else {
                                        // elFinder has already been initialized
                                        new Promise((res, rej) => {
                                            if (_fm.file(open)) {
                                                res();
                                            } else {
                                                // To acquire target folder information
                                                _fm.request({cmd: 'parents', target: open}).done(e => {
                                                    _fm.file(open) ? res() : rej();
                                                }).fail(() => {
                                                    rej();
                                                });
                                            }
                                        }).then(() => {
                                            // Open folder after folder information is acquired
                                            _fm.exec('open', open).done(() => {
                                                resolve(_fm);
                                            }).fail(err => {
                                                reject(_fm, err ? err : 'errFolderNotFound');
                                            });
                                        }).catch((err) => {
                                            reject(_fm, err ? err : 'errFolderNotFound');
                                        });
                                    }
                                } else {
                                    // show elFinder manager only
                                    resolve(_fm);
                                }
                            };

                            // Check elFinder instance
                            if (_fm) {
                                // elFinder instance has already been created
                                done();
                            } else {
                                // To create elFinder instance
                                _fm = $('<div/>').dialogelfinder({
                                    // dialog title
                                    title: 'File Manager',
                                    // connector URL
                                    url: connectorUrl,
                                    // start folder setting
                                    startPathHash: open ? open : void (0),
                                    // Set to do not use browser history to un-use location.hash
                                    useBrowserHistory: false,
                                    // Disable auto open
                                    autoOpen: false,
                                    // elFinder dialog width
                                    width: '80%',
                                    // set getfile command options
                                    commandsOptions: {
                                        getfile: {
                                            oncomplete: 'close',
                                            multiple: true
                                        }
                                    },
                                    lang: 'fr',
                                    // Insert in CKEditor when choosing files
                                    getFileCallback: (files, fm) => {
                                        let imgs = [];
                                        fm.getUI('cwd').trigger('unselectall');
                                        $.each(files, function (i, f) {
                                            if (f && f.mime.match(/^image\//i)) {
                                                imgs.push(fm.convAbsUrl(f.url));
                                            } else {
                                                editor.execute('link', fm.convAbsUrl(f.url));
                                            }
                                        });
                                        if (imgs.length) {
                                            insertImages(imgs);
                                        }
                                    }
                                }).elfinder('instance');
                                done();
                            }
                        });
                    };

                // elFinder instance
                let _fm;

                if (ckf) {
                    // Take over ckfinder execute()
                    ckf.execute = () => {
                        getfm().then(fm => {
                            fm.getUI().dialogelfinder('open');
                        });
                    };
                }

                // Make uploader
                const uploder = function (loader) {
                    let upload = function (file, resolve, reject) {
                        getfm(uploadTargetHash).then(fm => {
                            let fmNode = fm.getUI();
                            fmNode.dialogelfinder('open');
                            fm.exec('upload', {files: [file], target: uploadTargetHash}, void (0), uploadTargetHash)
                                .done(data => {
                                    if (data.added && data.added.length) {
                                        fm.url(data.added[0].hash, {async: true}).done(function (url) {
                                            resolve({
                                                'default': fm.convAbsUrl(url)
                                            });
                                            fmNode.dialogelfinder('close');
                                        }).fail(function () {
                                            reject('errFileNotFound');
                                        });
                                    } else {
                                        reject(fm.i18n(data.error ? data.error : 'errUpload'));
                                        fmNode.dialogelfinder('close');
                                    }
                                })
                                .fail(err => {
                                    const error = fm.parseError(err);
                                    reject(fm.i18n(error ? (error === 'userabort' ? 'errAbort' : error) : 'errUploadNoFiles'));
                                });
                        }).catch((fm, err) => {
                            const error = fm.parseError(err);
                            reject(fm.i18n(error ? (error === 'userabort' ? 'errAbort' : error) : 'errUploadNoFiles'));
                        });
                    };

                    this.upload = function () {
                        return new Promise(function (resolve, reject) {
                            if (loader.file instanceof Promise || (loader.file && typeof loader.file.then === 'function')) {
                                loader.file.then(function (file) {
                                    upload(file, resolve, reject);
                                });
                            } else {
                                upload(loader.file, resolve, reject);
                            }
                        });
                    };
                    this.abort = function () {
                        _fm && _fm.getUI().trigger('uploadabort');
                    };
                };

                // Set up image uploader
                fileRepo.createUploadAdapter = loader => {
                    return new uploder(loader);
                };
            });
        })
    </script>
    {{ encore_entry_script_tags('editor_init') }}
{% endblock %}
{% block stylesheets %}
    {{ parent() }}

    {{ encore_entry_link_tags('editor_init') }}
    <link href="{{ asset('build/jquery-ui/themes/base/theme.css') }}" rel="stylesheet" type="text/css">

    <link href="{{ asset('bundles/fmelfinder/css/elfinder.min.css') }}" rel="stylesheet" type="text/css">
    <style>
        .ck-editor__main, .ck-content {
            height:300px !important;
        }
    </style>
{% endblock %}

Le POC est sur Github : https://github.com/doxt3r/symfony6-ckeditor5-elfinder, si tu as des améliorations n'hésite pas à créer un PR., et surtout, si tu kiffes cet effort, paie tes BATs (ou autres) et enjoy ! :D

 

0 commentaire(s)