diff options
author | polo <ordipolo@gmx.fr> | 2025-03-21 00:47:16 +0100 |
---|---|---|
committer | polo <ordipolo@gmx.fr> | 2025-03-21 00:47:16 +0100 |
commit | 141291c29306391f91e7ceee57ff4d6e9907ab1d (patch) | |
tree | 3896896e18f4c8d84c8108e1c80e3d8c7093e712 /src/service | |
parent | 51a5391c96043829f7bbccda1262a51a8b8f93d8 (diff) | |
download | ckeditor5-master.zip |
Diffstat (limited to 'src/service')
-rw-r--r-- | src/service/ckeditor5/CKEditor.php | 204 | ||||
-rw-r--r-- | src/service/ckeditor5/image_upload.php | 65 | ||||
-rw-r--r-- | src/service/ckeditor5/view.php | 181 |
3 files changed, 450 insertions, 0 deletions
diff --git a/src/service/ckeditor5/CKEditor.php b/src/service/ckeditor5/CKEditor.php new file mode 100644 index 0000000..3adf223 --- /dev/null +++ b/src/service/ckeditor5/CKEditor.php | |||
@@ -0,0 +1,204 @@ | |||
1 | <?php | ||
2 | // src/integration/ckeditor5/CKEditor.php | ||
3 | |||
4 | declare(strict_types=1); | ||
5 | |||
6 | class CKEditor | ||
7 | { | ||
8 | public const string INTEGRATION_PATH = '../src/service/ckeditor5/'; | ||
9 | public const string DATA_PATH = '../data/page'; | ||
10 | //public const string SERVER_PATH = '/'; | ||
11 | private const string SERVER_PATH = '/ckeditor5/'; | ||
12 | |||
13 | private string $server_root; // pour les "imports" en javascript | ||
14 | // pour l'importmap: j'ai modifié la version "installation avec CDN de la doc pour utiliser les fichiers locaux | ||
15 | // l'"importmap" permet d'utiliser "import" (ça ressemble pas mal au python) dans le navigateur comme n'importe quel langage de programmation normal | ||
16 | |||
17 | private string $storage_mode = 'files'; | ||
18 | private string $page = 'accueil'; | ||
19 | private string $from = ''; | ||
20 | |||
21 | private int $php_ini_max_size; | ||
22 | private string $css_editor_tag = ''; | ||
23 | private string $css_outside_editor_tag = ''; | ||
24 | private string $toolbar_language = 'en'; | ||
25 | |||
26 | |||
27 | public function __construct() | ||
28 | { | ||
29 | $this->php_ini_max_size = $this->returnBytes(ini_get('upload_max_filesize')); // = 2M par défaut dans le php.ini | ||
30 | $this->css_outside_editor_tag = '<link rel="stylesheet" href="css/article_hors_editeur.css" />'; | ||
31 | $this->server_root = $_SERVER['SERVER_NAME'] . self::SERVER_PATH; | ||
32 | } | ||
33 | |||
34 | // getters | ||
35 | public function getPage(): string | ||
36 | { | ||
37 | return $this->page; | ||
38 | } | ||
39 | public function getFrom(): string | ||
40 | { | ||
41 | return $this->from; | ||
42 | } | ||
43 | public function getCSSOutsideEditorTag(): string | ||
44 | { | ||
45 | return $this->css_outside_editor_tag; | ||
46 | } | ||
47 | public function getCSSEditorTag(): string | ||
48 | { | ||
49 | return $this->css_editor_tag; | ||
50 | } | ||
51 | public function getStorageMode(): string | ||
52 | { | ||
53 | return $this->storage_mode; | ||
54 | } | ||
55 | |||
56 | // setters | ||
57 | public function setPage(string $page): void | ||
58 | { | ||
59 | $this->page = $page; | ||
60 | } | ||
61 | public function setFrom(string $from): void | ||
62 | { | ||
63 | $this->from = $from; | ||
64 | } | ||
65 | public function setStorageMode($mode): void | ||
66 | { | ||
67 | $this->storage_mode = $mode; | ||
68 | } | ||
69 | |||
70 | public function setLanguage(string $language): void | ||
71 | { | ||
72 | $this->toolbar_language = $language; | ||
73 | $this->makeTranslationSymLink(); | ||
74 | } | ||
75 | |||
76 | public function checkAjaxRequest(): void | ||
77 | { | ||
78 | // dans un fichier à part parce que trop de lignes | ||
79 | require self::INTEGRATION_PATH . 'image_upload.php'; | ||
80 | die; | ||
81 | } | ||
82 | |||
83 | public function displayNewArticleButton(): string | ||
84 | { | ||
85 | $html = '<article> | ||
86 | <a href="index.php?page=' . $this->page . '&action=open_editor"><button>Nouvel article</button></a> | ||
87 | </article>'; | ||
88 | return $html; | ||
89 | } | ||
90 | public function displayArticle(string $id, string $texte): string | ||
91 | { | ||
92 | $html = '<article>' . $texte . '</article> | ||
93 | <a href="index.php?page=' . $this->page . '&action=open_editor&id=' . $id . '"><button>Modifier</button></a> | ||
94 | <a href="index.php?page=' . $this->page . '&action=delete_article&id=' . $id . '"><button>Supprimer</button></a>'; | ||
95 | return $html; | ||
96 | } | ||
97 | |||
98 | public function openEditor(string $id = '', string $texte = ''): string | ||
99 | { | ||
100 | // version "minifiée" | ||
101 | $this->css_editor_tag = '<link rel="stylesheet" href="../node_modules/ckeditor5/dist/browser/ckeditor5.css" />'; | ||
102 | // version normale aérée et commentée" | ||
103 | $this->css_editor_tag = '<link rel="stylesheet" href="../node_modules/ckeditor5/dist/ckeditor5.css" />'; | ||
104 | |||
105 | require self::INTEGRATION_PATH . 'view.php'; // html + JS | ||
106 | return $editeurHTML; | ||
107 | } | ||
108 | |||
109 | //public function deleteSideEffects(string $id){} | ||
110 | |||
111 | public function checkSubmitPOST(): string | ||
112 | { | ||
113 | return $this->getAndCleanEditorOutput($_POST["contenu"]); // manipule $_POST['contenu']; | ||
114 | |||
115 | // enregistrement des données | ||
116 | //var_dump($html_from_editor); // debug | ||
117 | //echo "ce programme n'enregistre rien, faîtes-le dans votre application et supprimer cette ligne dans index.php"; die; | ||
118 | |||
119 | /*header('Location: ' . $this->from); | ||
120 | die;*/ | ||
121 | } | ||
122 | |||
123 | /* lien symbolique des traductions | ||
124 | l'éditeur recherche un dans module/ckeditor5/dist/browser/translations | ||
125 | un fichier se trouvant dans module/ckeditor5/dist/translations | ||
126 | c'est le meilleur moyen que j'ai trouvé de gérer ça (il y a surement mieux) */ | ||
127 | private function makeTranslationSymLink(): void | ||
128 | { | ||
129 | $target = '../translations'; | ||
130 | $link = '../node_modules/ckeditor5/dist/browser/translations'; | ||
131 | |||
132 | if(!file_exists($link)) | ||
133 | { | ||
134 | if(PHP_OS_FAMILY === 'Linux') | ||
135 | { | ||
136 | symlink($target, $link); | ||
137 | } | ||
138 | elseif(PHP_OS_FAMILY === 'Windows') // note: PHP_OS = WINNT | ||
139 | { | ||
140 | // on peut créer une jointure sans droit d'admin | ||
141 | $target = '..\node_modules\ckeditor5\dist\translations'; | ||
142 | exec('mklink /J ' . str_replace('/', '\\', $link) . ' ' . $target); | ||
143 | } | ||
144 | else | ||
145 | { | ||
146 | echo "erreur dans " . self::INTEGRATION_PATH . "CKEditor.php: système d'exploitation n'a pas été reconnu"; | ||
147 | } | ||
148 | // autres valeurs possibles pour PHP_OS_FAMILY: 'BSD', 'Darwin', 'Solaris', 'Unknown' | ||
149 | } | ||
150 | } | ||
151 | |||
152 | // conversion des 2M du php.ini en 2000000 | ||
153 | // note: les kibi, mébi et gibi sont inutiles ici | ||
154 | private function returnBytes(string $size_str): int // chaine du style '2M' | ||
155 | { | ||
156 | switch(substr($size_str, -1)) | ||
157 | { | ||
158 | case 'Ki': case 'ki': return (int)$size_str * 1024; | ||
159 | case 'Mi': case 'mi': return (int)$size_str * 1048576; | ||
160 | case 'Gi': case 'gi': return (int)$size_str * 1073741824; | ||
161 | case 'K': case 'k': return (int)$size_str * 1000; | ||
162 | case 'M': case 'm': return (int)$size_str * 1000000; | ||
163 | case 'G': case 'g': return (int)$size_str * 1000000000; | ||
164 | default: return (int)$size_str; | ||
165 | } | ||
166 | } | ||
167 | |||
168 | private function getAndCleanEditorOutput(string $html): string | ||
169 | { | ||
170 | // bugs possibles sans trim() lorsqu'on insère le HTML dans l'éditeur | ||
171 | $html = trim($html); | ||
172 | |||
173 | //$this->checkContentInFile($html, 'avant'); | ||
174 | |||
175 | // sécurisation du HTML (faille XSS) | ||
176 | require '../vendor/htmlawed/htmlawed/htmLawed.php'; | ||
177 | $configHtmLawed = array( | ||
178 | 'safe'=>1, // protection contre les élements et attributs dangereux | ||
179 | |||
180 | // balises autorisées | ||
181 | 'elements'=>'h2, h3, h4, p, span, i, strong, u, s, mark, blockquote, li, ol, ul, a, figure, hr, img, figcaption, table, tbody, tr, td', | ||
182 | // note: change <s></s> en <span style="text-decoration: line-through;"></span> | ||
183 | |||
184 | // attributs interdits | ||
185 | 'deny_attribute'=>'id', // 'class' et 'style' sont conservés pour le ckeditor | ||
186 | ); | ||
187 | $specHtmLawed = ''; // optionnel: faire qu'un certain élément puisse n'avoir que certains attributs | ||
188 | $html = htmLawed($html, $configHtmLawed, $specHtmLawed); | ||
189 | |||
190 | //$this->checkContentInFile($html, 'après'); | ||
191 | |||
192 | return $html; | ||
193 | } | ||
194 | |||
195 | |||
196 | private function checkContentInFile($html, $file_name): void | ||
197 | { | ||
198 | $nom_fichier = $file_name . ".html"; | ||
199 | $fichier = fopen(self::DATA_PATH . '/' . $nom_fichier, 'w'); // w peut créer un fichier, si il existe déjà, il est effacé par le nouveau contenu | ||
200 | fputs($fichier, $html); | ||
201 | fclose($fichier); | ||
202 | chmod(self::DATA_PATH . '/' . $nom_fichier, 0666); | ||
203 | } | ||
204 | } | ||
diff --git a/src/service/ckeditor5/image_upload.php b/src/service/ckeditor5/image_upload.php new file mode 100644 index 0000000..5cef369 --- /dev/null +++ b/src/service/ckeditor5/image_upload.php | |||
@@ -0,0 +1,65 @@ | |||
1 | <?php | ||
2 | // src/integration/ckeditor5/image_upload.php | ||
3 | |||
4 | declare(strict_types=1); | ||
5 | |||
6 | // le "simple upload adapter" envoie un POST appelé: $_FILES['upload'] | ||
7 | // en retour il attend impérativement des données au format JSON du genre: {"url": "data/page/images/monfichier.jpg"} | ||
8 | // cette adresse doit permettre à l'éditeur de télécharger l'image afficher de manière normale: <img scr="data/page/images/monfichier.jpg"> | ||
9 | |||
10 | $erreur = ''; | ||
11 | if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['upload']) | ||
12 | && strpos($_SERVER['CONTENT_TYPE'], 'multipart/form-data') !== false) // le "simple upload adapter" envoie des "form-data" | ||
13 | { | ||
14 | if($_FILES['upload']['error'] == 0) // 0 signifie ok | ||
15 | { | ||
16 | if($_FILES['upload']['size'] <= $this->php_ini_max_size) | ||
17 | { | ||
18 | $infos = pathinfo ($_FILES['upload']['name']); | ||
19 | $extension = $infos['extension']; | ||
20 | $extautorisées = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff']; | ||
21 | // on prend la même liste que celle côté javascript, le SVG est bloqué pour raison de sécurité (javascript à l'intérieur) | ||
22 | if(in_array($extension, $extautorisées)) | ||
23 | { | ||
24 | move_uploaded_file ($_FILES['upload']['tmp_name'], self::DATA_PATH . '/images/' . $_FILES['upload']['name']); | ||
25 | chmod(self::DATA_PATH . '/images/' . $_FILES['upload']['name'], 0666); | ||
26 | } | ||
27 | else | ||
28 | { | ||
29 | $erreur = 'mauvais format, veuillez utiliser une image comportant un de ces formats: jpg ou jpeg, png, gif, bmp, webp, tiff<br />le format svg n\'est pas supporté'; | ||
30 | } | ||
31 | } | ||
32 | else | ||
33 | { | ||
34 | $erreur = 'fichier trop lourd'; | ||
35 | } | ||
36 | } | ||
37 | else | ||
38 | { | ||
39 | $erreur = $_FILES['upload']['error']; | ||
40 | } | ||
41 | |||
42 | if(empty($erreur)) | ||
43 | { | ||
44 | $chemin = '{"url": "' . self::DATA_PATH . '/images/' . $_FILES['upload']['name'] . '"}'; | ||
45 | echo $chemin; | ||
46 | } | ||
47 | else | ||
48 | { | ||
49 | echo $erreur; | ||
50 | } | ||
51 | } | ||
52 | else | ||
53 | { | ||
54 | echo "erreur: téléchargement non identifié"; | ||
55 | } | ||
56 | |||
57 | /* les erreurs retournées avec $_FILES['upload']['error']: | ||
58 | 0 pas d'erreur | ||
59 | 1 taille du fichier supérieure à la valeur de upload_max_filesize dans le fichier php.ini (par défaut = 2 MO) | ||
60 | 2 taille du fichier supérieure à la valeur de MAX_FILE_SIZE dans le formulaire HTML | ||
61 | 3 fichier partiellement téléchargé | ||
62 | 4 pas de fichier du tout | ||
63 | 6 pas de dossier temporaire pour mettre le fichier | ||
64 | 7 echec de l'écriture sur le DD | ||
65 | 8 envoi arrêté par une extension de PHP mais on ne nous dit pas pourquoi => diagnostic avec la fonction phpinfo() */ \ No newline at end of file | ||
diff --git a/src/service/ckeditor5/view.php b/src/service/ckeditor5/view.php new file mode 100644 index 0000000..57115d1 --- /dev/null +++ b/src/service/ckeditor5/view.php | |||
@@ -0,0 +1,181 @@ | |||
1 | <?php | ||
2 | // src/integration/ckeditor5/view.php | ||
3 | |||
4 | declare(strict_types=1); | ||
5 | |||
6 | $submit_action = 'index.php?from=' . $this->page . '&action=submit_editor&id=' . $id; | ||
7 | |||
8 | $cancel_button = 'index.php?page=' . $this->page; | ||
9 | //$cancel_button .= !empty($id) ? '&id=' . $id : ''; // à ajouter si on a besoin de revenir sur un article spécifique | ||
10 | |||
11 | $delete_button = $id != '' ? '<a href="index.php?page=' . $this->page . '&action=delete_article&id=' . $id . '"><input type="button" value="Supprimer"></a>' : ''; | ||
12 | |||
13 | $upload_ajax = 'index.php?action=upload_image_editor'; | ||
14 | $upload_ajax .= !empty($id) ? '&id=' . $id : ''; | ||
15 | |||
16 | ob_start(); | ||
17 | ?> | ||
18 | <article> | ||
19 | <form action="<?= $submit_action ?>" method="POST" enctype="multipart/form-data" > | ||
20 | <textarea id="editor" name="contenu" ></textarea> | ||
21 | <input type="submit" value="Valider"> | ||
22 | <a href="<?= $cancel_button ?>"><input type="button" value="Annuler"></a> | ||
23 | <?= $delete_button ?> | ||
24 | <script type="importmap"> | ||
25 | { | ||
26 | "imports": { | ||
27 | "ckeditor5": "http://<?= $this->server_root ?>node_modules/ckeditor5/dist/browser/ckeditor5.js", | ||
28 | "ckeditor5/": "http://<?= $this->server_root ?>node_modules/ckeditor5/dist/browser/" | ||
29 | } | ||
30 | } | ||
31 | </script> | ||
32 | <script type="module"> | ||
33 | import { // nécessite type="module" | ||
34 | ClassicEditor, Essentials, Heading, Paragraph, Alignment, List, | ||
35 | BlockQuote, HorizontalLine, Bold, Italic, Underline, Strikethrough, | ||
36 | Font, FontFamily, Highlight, FontBackgroundColor, SimpleUploadAdapter, | ||
37 | Image, ImageInsert, ImageToolbar, ImageStyle, ImageCaption, LinkImage, | ||
38 | Link, Table, TableColumnResize, TableToolbar, TableProperties, TableCellProperties, TextPartLanguage | ||
39 | } from "ckeditor5"; | ||
40 | |||
41 | import coreTranslations from 'ckeditor5/translations/<?= $this->toolbar_language ?>.js'; | ||
42 | // n'utilise pas le bon chemin à cause d'un bug? solution = créer un lien symbolique à l'endroit attendu: | ||
43 | // ln -s /srv/http/ckeditor5/node_modules/ckeditor5/dist/translations /srv/http/ckeditor5/node_modules/ckeditor5/dist/browser/ | ||
44 | |||
45 | //import 'ckeditor5/ckeditor5.css'; // marche pas chez moi | ||
46 | |||
47 | let editor; | ||
48 | let html_existant = '<?= $texte ?>'; // $texte doit avoir été sécurisé: simple quotes échappées au minimum | ||
49 | let upload_url = '<?= $upload_ajax ?>'; | ||
50 | |||
51 | // ATTENTION: si l'éditeur ne fonctionne pas, empêcher qu'on puisse cliquer sur Valider! | ||
52 | // Il y a aussi des paramètres dans le fichier de config: ckeditor/webpack.config.js | ||
53 | ClassicEditor | ||
54 | .create( document.querySelector( '#editor' ),{ | ||
55 | |||
56 | licenseKey: 'GPL', | ||
57 | |||
58 | plugins: [ Essentials, Heading, Paragraph, Alignment, List, | ||
59 | BlockQuote, HorizontalLine, Underline, Strikethrough, | ||
60 | Bold, Italic, Font, FontFamily, Highlight, FontBackgroundColor, SimpleUploadAdapter, | ||
61 | Image, ImageInsert, ImageToolbar, ImageStyle, ImageCaption, LinkImage, | ||
62 | Link, Table, TableColumnResize, TableToolbar, TableProperties, TableCellProperties, TextPartLanguage], | ||
63 | |||
64 | toolbar: { | ||
65 | items: [ | ||
66 | 'undo', 'redo', 'selectAll', '|', 'heading', '|', 'alignment', 'bulletedList', 'numberedList', | ||
67 | //'todoList',// marche pas, ne crée pas de HTML | ||
68 | 'blockQuote', 'horizontalLine', '|', 'textPartLanguage', '-', 'bold', 'italic', 'underline', 'strikethrough', '|', | ||
69 | 'fontFamily', // polices microsoft uniquement | ||
70 | 'fontColor', 'fontSize', 'highlight', 'fontBackgroundColor', // un peu comme highlight | ||
71 | '|', 'imageInsert', 'link', | ||
72 | //'htmlEmbed', // marche pas, ne crée pas de HTML | ||
73 | //'mediaEmbed', | ||
74 | 'insertTable', | ||
75 | ], | ||
76 | // multiligne automatique (le '-' dans la liste permet de choisir l'endroit où couper) | ||
77 | // pour les plugins indiqués "marche pas", envoient-ils un requête AJAX quelque part? | ||
78 | |||
79 | shouldNotGroupWhenFull: true | ||
80 | }, | ||
81 | |||
82 | language: '<?= $this->toolbar_language ?>', | ||
83 | translations: [coreTranslations], | ||
84 | |||
85 | // barre d'outils dans une image | ||
86 | image: { | ||
87 | toolbar: [ | ||
88 | //'imageStyle:full', //inutile? | ||
89 | 'imageStyle:block', | ||
90 | 'imageStyle:inline', // complexe, on peut aussi placer l'image à la souris | ||
91 | 'imageStyle:side', | ||
92 | /*'imageStyle:align-left', | ||
93 | 'imageStyle:align-right',*/ | ||
94 | //'imageResize', // optionnel? on a les poignées dans les coins de l'image | ||
95 | 'linkImage', | ||
96 | 'toggleImageCaption', | ||
97 | 'imageTextAlternative' | ||
98 | ] | ||
99 | }, | ||
100 | |||
101 | // barre d'outils dans un tableau | ||
102 | table: { | ||
103 | contentToolbar: [ | ||
104 | 'tableColumn', // manipulation sur les colonnes et lignes | ||
105 | 'tableRow', | ||
106 | 'mergeTableCells', | ||
107 | 'tableProperties', // style sur la table | ||
108 | 'tableCellProperties' // style sur une cellule | ||
109 | ] | ||
110 | }, | ||
111 | |||
112 | // langues dispo pour TextPartLanguage | ||
113 | language: { | ||
114 | textPartLanguage: [ | ||
115 | { title: 'Arabic', languageCode: 'ar' }, | ||
116 | { title: 'English', languageCode: 'en' }, | ||
117 | { title: 'French', languageCode: 'fr' }, | ||
118 | { title: 'German', languageCode: 'de' }, | ||
119 | { title: 'Hebrew', languageCode: 'he' }, | ||
120 | { title: 'Spanish', languageCode: 'es' } | ||
121 | ] | ||
122 | }, | ||
123 | |||
124 | // plugin code HTML | ||
125 | // voir doc | ||
126 | |||
127 | // media embarqué (audio, vidéo, carte) | ||
128 | //mediaEmbed: { | ||
129 | //previewsInData: true, | ||
130 | // vaut "false" par defaut, on crée la balise non standard <oembed url="" > // https://oembed.com/ | ||
131 | // qui nécessite un traitement (en JS ou côté serveur) en utilisant le lien à l'intérieur | ||
132 | // avec "true", on crée la balise <iframe> qui sert à insérer une page HTML dans une autre, | ||
133 | // notre "embarquement de média" devrait donc réussir quelque soit le site | ||
134 | // c'est plus simple, il ne reste plus qu'à ajuster le contenu avec du CSS (important) | ||
135 | // on doit supprimer le positionnement absolu qui fait que l'iframe se place par dessus le reste et adapter la taille de l'élément | ||
136 | |||
137 | // en outre, le plugin mediaEmbed (dans l'éditeur), tout comme la balise "iframe" (hors éditeur), | ||
138 | // permettent d'afficher un aperçu (preview): une image avec un bouton lecture dessus | ||
139 | // cette image est envoyée imédiatement par le serveur, et le contenu après un clic dessus | ||
140 | // | ||
141 | //}, | ||
142 | |||
143 | // images | ||
144 | // le plugin "simple upload adapter" communique avec le serveur au moyen de requêtes AJAX | ||
145 | // il attend du serveur une réponse au format .json contenant l'adresse où est stockée l'image: | ||
146 | // {"url": "https://example.com/images/foo.jpg"} | ||
147 | simpleUpload: { | ||
148 | uploadUrl: upload_url, | ||
149 | |||
150 | // authentifier l'envoyeur + protection au CSRF | ||
151 | headers: { | ||
152 | 'X-CSRF-TOKEN': 'CSRF-Token', | ||
153 | Authorization: 'Bearer <JSON Web Token>' | ||
154 | } | ||
155 | }, | ||
156 | // formats acceptés par défaut: jpeg, png, gif, bmp, webp, tiff | ||
157 | // le svg n'est pas dans la liste, pour raison de sécurité apparemment, il parait qu'on peut mettre du javascript à l'intérieur | ||
158 | |||
159 | // plugin autosave | ||
160 | // voir doc | ||
161 | } ) | ||
162 | .then( newEditor => { | ||
163 | editor = newEditor; | ||
164 | // fin de ClassicEditor | ||
165 | |||
166 | // obtenir la liste des éléments disponibles pour la barre d'outils | ||
167 | //alert(Array.from( editor.ui.componentFactory.names() )); | ||
168 | // obtenir la liste des plugins disponibles: | ||
169 | //alert(ClassicEditor.builtinPlugins.map( plugin => plugin.pluginName )); | ||
170 | |||
171 | // insertion du HTML existant | ||
172 | editor.setData(html_existant); | ||
173 | } ) | ||
174 | .catch( error => { | ||
175 | console.error( error ); | ||
176 | } ); | ||
177 | </script> | ||
178 | </form> | ||
179 | </article> | ||
180 | <?php | ||
181 | $editeurHTML = ob_get_clean(); | ||