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 :
- Autoformat : la mise en forme automatique du texte pendant la saisie.
- Basic Styles : les styles de base pour le texte. Ils permettent d'appliquer les styles gras, italique et barré au texte sélectionné.
- Block Quote : les citations.
- CKFinder : intègre CKEditor 5 avec ou autres gestionnaires de fichiers (filemanager). Il facilite l'insertion et la gestion d'images et d'autres fichiers multimédias dans l'éditeur.
- CKFinderUploadAdapter : fournit un adaptateur pour CKFinder. Il permet d'intégrer la fonctionnalité d'upload de fichiers du filemanager.
- Code Block : permet d'insérer et de styliser des blocs de code dans le contenu, très utile dans le cas de mon blog.
- Essentials : fournit les fonctionnalités fondamentales de l'éditeur, telles que la barre d'outils principale, l'intégration du presse-papiers et les fonctionnalités d'annulation de modifs.
- Heading : permet d'utiliser différents niveaux de titres (h1, h2, h3, etc.) dans l'éditeur.
- Highlight : permet de mettre en évidence/surligner du texte.
- Image : pour l'insertion et la gestion des images.
- Link : pour l'insertion et la gestion des liens.
- List : pour inserer des listes.
- Paragraph : tout est dans le nom.
- Table : pour gérer les tableaux.
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 :
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