‪TYPO3CMS  ‪main
CKEditor5Migrator.php
Go to the documentation of this file.
1 <?php
2 
3 declare(strict_types=1);
4 
5 /*
6  * This file is part of the TYPO3 CMS project.
7  *
8  * It is free software; you can redistribute it and/or modify it under
9  * the terms of the GNU General Public License, either version 2
10  * of the License, or any later version.
11  *
12  * For the full copyright and license information, please read the
13  * LICENSE.txt file that was distributed with this source code.
14  *
15  * The TYPO3 project - inspiring people to share!
16  */
17 
19 
21 
26 {
31  private const ‪TOOLBAR_MAIN_GROUPS_MAP = [
32  'document' => ['mode', 'document', 'doctools'],
33  'clipboard' => ['clipboard', 'undo'],
34  'editing' => ['find', 'selection', 'spellchecker', 'editing'],
35  'forms' => ['forms'],
36  'basicstyles' => ['basicstyles', 'cleanup'],
37  'paragraph' => ['list', 'indent', 'blocks', 'align', 'bidi', 'paragraph'],
38  'links' => ['links'],
39  'insert' => ['insert'],
40  'styles' => ['styles'],
41  'colors' => ['colors'],
42  'tools' => ['tools'],
43  'others' => ['others'],
44  'about' => ['about'],
45  'blocks' => ['blocks'],
46  'table' => ['table'],
47  'tabletools' => [],
48  ];
49 
53  private const ‪TOOLBAR_GROUPS_MAP = [
54  'mode' => ['Source'],
55  'document' => ['Save', 'NewPage', 'Preview', 'Print'],
56  'doctools' => ['Templates'],
57  'clipboard' => ['Cut', 'Copy', 'Paste', 'PasteText', 'PasteFromWord'],
58  'undo' => ['Undo', 'Redo'],
59  'find' => ['Find', 'Replace'],
60  'selection' => ['SelectAll'],
61  'spellchecker' => ['Scayt'],
62  'forms' => ['Form', 'Checkbox', 'Radio', 'TextField', 'Textarea', 'Select', 'Button', 'ImageButton', 'HiddenField'],
63  'basicstyles' => ['Bold', 'Italic', 'Underline', 'Strike', 'Subscript', 'Superscript', 'SoftHyphen'],
64  'cleanup' => ['CopyFormatting', 'RemoveFormat'],
65  'list' => ['NumberedList', 'BulletedList'],
66  'indent' => ['Indent', 'Outdent'],
67  'blocks' => ['Blockquote', 'CreateDiv'],
68  'align' => ['JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock'],
69  'bidi' => ['BidiLtr', 'BidiRtl', 'Language'],
70  'links' => ['Link', 'Unlink', 'Anchor'],
71  'insert' => ['Image', 'Flash', 'Table', 'HorizontalRule', 'Smiley', 'SpecialChar', 'PageBreak', 'Iframe'],
72  'styles' => ['Styles', 'Format', 'Font', 'FontSize'],
73  'format' => ['Format'],
74  'table' => ['Table'],
75  'specialchar' => ['SpecialChar'],
76  'colors' => ['TextColor', 'BGColor'],
77  'tools' => ['Maximize', 'ShowBlocks'],
78  'about' => ['About'],
79  'others' => [],
80  ];
81 
82  // List of "old" button names vs the replacement(s)
83  private const ‪BUTTON_MAP = [
84  // mode
85  'Source' => 'sourceEditing',
86  // document
87  'Save' => null,
88  'NewPage' => null,
89  'Preview' => null,
90  'Print' => null,
91  // doctools
92  'Templates' => null,
93  // clipboard
94  'Cut' => null,
95  'Copy' => null,
96  'Paste' => null,
97  'PasteText' => null,
98  'PasteFromWord' => null,
99  // undo
100  'Undo' => 'undo',
101  'Redo' => 'redo',
102  // find
103  'Find' => null,
104  'Replace' => 'findAndReplace',
105  // selection
106  'SelectAll' => 'selectAll',
107  // spellchecker
108  'Scayt' => null,
109  // forms
110  'Form' => null,
111  'Checkbox' => null,
112  'Radio' => null,
113  'TextField' => null,
114  'Textarea' => null,
115  'Select' => null,
116  'Button' => null,
117  'ImageButton' => null,
118  'HiddenField' => null,
119  // basicstyles
120  'Bold' => 'bold',
121  'Italic' => 'italic',
122  'Underline' => 'underline',
123  'Strike' => 'strikethrough',
124  'Subscript' => 'subscript',
125  'Superscript' => 'superscript',
126  // cleanup
127  'CopyFormatting' => null,
128  'RemoveFormat' => 'removeFormat',
129  // list
130  'NumberedList' => 'numberedList',
131  'BulletedList' => 'bulletedList',
132  // indent
133  'Outdent' => 'outdent',
134  'Indent' => 'indent',
135  // blocks
136  'Blockquote' => 'blockQuote',
137  'CreateDiv' => null,
138  // align
139  'JustifyLeft' => 'alignment:left',
140  'JustifyCenter' => 'alignment:center',
141  'JustifyRight' => 'alignment:right',
142  'JustifyBlock' => 'alignment:justify',
143  // bidi
144  'BidiLtr' => null,
145  'BidiRtl' => null,
146  'Language' => 'textPartLanguage',
147  // links
148  'Link' => 'link',
149  'Unlink' => null,
150  'Anchor' => null,
151  // insert
152  'Image' => 'insertImage',
153  'Flash' => null,
154  'Table' => 'insertTable',
155  'HorizontalRule' => 'horizontalLine',
156  'Smiley' => null,
157  'SpecialChar' => 'specialCharacters',
158  'PageBreak' => 'pageBreak',
159  'Iframe' => null,
160  // styles
161  'Styles' => 'style',
162  'Format' => 'heading',
163  'Font' => null,
164  'FontSize' => null,
165  // colors
166  'TextColor' => null,
167  'BGColor' => null,
168  // tools
169  'Maximize' => null,
170  'ShowBlocks' => null,
171  // about
172  'About' => null,
173  // typo3
174  'SoftHyphen' => 'softhyphen',
175  ];
176 
180  private const ‪PLUGIN_MAP = [
181  'image' => [
182  'module' => '@ckeditor/ckeditor5-image',
183  'exports' => [ 'Image', 'ImageCaption', 'ImageStyle', 'ImageToolbar', 'ImageUpload', 'PictureEditing' ],
184  ],
185  'Image' => [
186  'module' => '@ckeditor/ckeditor5-image',
187  'exports' => [ 'Image', 'ImageCaption', 'ImageStyle', 'ImageToolbar', 'ImageUpload', 'PictureEditing' ],
188  ],
189  'alignment' => [
190  'module' => '@ckeditor/ckeditor5-alignment',
191  'exports' => [ 'Alignment' ],
192  ],
193  'Alignment' => [
194  'module' => '@ckeditor/ckeditor5-alignment',
195  'exports' => [ 'Alignment' ],
196  ],
197  'autolink' => [
198  'module' => '@ckeditor/ckeditor5-link',
199  'exports' => [ 'AutoLink' ],
200  ],
201  'AutoLink' => [
202  'module' => '@ckeditor/ckeditor5-link',
203  'exports' => [ 'AutoLink' ],
204  ],
205  'justify' => [
206  'module' => '@ckeditor/ckeditor5-alignment',
207  'exports' => [ 'Alignment' ],
208  ],
209  'showblocks' => [
210  'module' => '@ckeditor/ckeditor5-show-blocks',
211  'exports' => [ 'ShowBlocks' ],
212  ],
213  'ShowBlocks' => [
214  'module' => '@ckeditor/ckeditor5-show-blocks',
215  'exports' => [ 'ShowBlocks' ],
216  ],
217  'softhyphen' => [
218  'module' => '@typo3/rte-ckeditor/plugin/whitespace.js',
219  'exports' => [ 'Whitespace' ],
220  ],
221  'whitespace' => [
222  'module' => '@typo3/rte-ckeditor/plugin/whitespace.js',
223  'exports' => [ 'Whitespace' ],
224  ],
225  'Whitespace' => [
226  'module' => '@typo3/rte-ckeditor/plugin/whitespace.js',
227  'exports' => [ 'Whitespace' ],
228  ],
229  'wordcount' => [
230  'module' => '@ckeditor/ckeditor5-word-count',
231  'exports' => [ 'WordCount' ],
232  ],
233  'WordCount' => [
234  'module' => '@ckeditor/ckeditor5-word-count',
235  'exports' => [ 'WordCount' ],
236  ],
237  ];
238 
242  public function ‪__construct(protected array $configuration)
243  {
244  if (isset($this->configuration['editor']['config'])) {
245  $this->‪migrateExtraPlugins();
246  $this->‪migrateRemovePlugins();
247  $this->migrateToolbar();
253  $this->‪migrateAllowedContent();
254  // configure plugins
255  $this->‪handleAlignmentPlugin();
256  $this->‪handleWhitespacePlugin();
257  $this->‪handleWordCountPlugin();
258 
259  // sort by key
260  ksort($this->configuration['editor']['config']);
261  }
262 
263  if (isset($this->configuration['buttons']['link'])) {
265  }
266  }
267 
268  public function get(): array
269  {
270  return $this->configuration;
271  }
272 
273  protected function ‪migrateExtraPlugins(): void
274  {
275  if (!isset($this->configuration['editor']['config']['extraPlugins'])) {
276  return;
277  }
278 
279  foreach ($this->configuration['editor']['config']['extraPlugins'] as $entry) {
280  $moduleToBeLoaded = self::PLUGIN_MAP[$entry] ?? null;
281  if ($moduleToBeLoaded === null) {
282  continue;
283  }
284  $this->configuration['editor']['config']['importModules'][] = $moduleToBeLoaded;
285  $this->‪removeExtraPlugin($entry);
286  }
287  }
288 
289  protected function ‪migrateRemovePlugins(): void
290  {
291  if (!isset($this->configuration['editor']['config']['removePlugins'])) {
292  return;
293  }
294 
295  foreach ($this->configuration['editor']['config']['removePlugins'] as $key => $entry) {
296  $moduleToBeRemoved = self::PLUGIN_MAP[$entry] ?? null;
297  if ($moduleToBeRemoved !== null) {
298  unset($this->configuration['editor']['config']['removePlugins'][$key]);
299  $this->configuration['editor']['config']['removeImportModules'][] = $moduleToBeRemoved;
300  }
301  }
302  if (count($this->configuration['editor']['config']['removePlugins']) === 0) {
303  unset($this->configuration['editor']['config']['removePlugins']);
304  } else {
305  $this->configuration['editor']['config']['removePlugins'] = $this->‪getUniqueArrayValues($this->configuration['editor']['config']['removePlugins']);
306  }
307  }
308 
313  protected function migrateToolbar(): void
314  {
319  $toolbar = [
320  'items' => [],
321  'removeItems' => $this->configuration['editor']['config']['toolbar']['removeItems'] ?? [],
322  'shouldNotGroupWhenFull' => $this->configuration['editor']['config']['toolbar']['shouldNotGroupWhenFull'] ?? true,
323  ];
324 
325  // Migrate CKEditor4 toolbarGroups
326  // There can only be one configuration at a time, if 'toolbarGroups' is set
327  // we prefer this definition above the toolbar definition.
328  // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-toolbarGroups
329  if (is_array($this->configuration['editor']['config']['toolbarGroups'] ?? null)) {
330  $toolbar['items'] = $this->configuration['editor']['config']['toolbarGroups'];
331  unset($this->configuration['editor']['config']['toolbar'], $this->configuration['editor']['config']['toolbarGroups']);
332  }
333 
334  // Migrate CKEditor4 toolbar templates
335  // Resolve toolbar template and override current toolbar
336  // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-toolbar
337  if (is_string($this->configuration['editor']['config']['toolbar'] ?? null)) {
338  $toolbarName = 'toolbar_' . trim($this->configuration['editor']['config']['toolbar']);
339  if (is_array($this->configuration['editor']['config'][$toolbarName] ?? null)) {
340  $toolbar['items'] = $this->configuration['editor']['config'][$toolbarName];
341  unset($this->configuration['editor']['config']['toolbar'], $this->configuration['editor']['config'][$toolbarName]);
342  }
343  }
344 
345  // Collect toolbar items
346  if (is_array($this->configuration['editor']['config']['toolbar'] ?? null)) {
347  $toolbar['items'] = $this->configuration['editor']['config']['toolbar']['items'] ?? $this->configuration['editor']['config']['toolbar'];
348  }
349 
350  $toolbar['items'] = $this->‪migrateToolbarItems($toolbar['items']);
351  $this->configuration['editor']['config']['toolbar'] = $toolbar;
352  }
353 
354  protected function ‪migrateToolbarItems(array $items): array
355  {
356  $toolbarItems = [];
357  foreach ($items as $item) {
358  if (is_string($item)) {
359  $toolbarItems[] = $this->‪migrateToolbarButton($item);
360  continue;
361  }
362  if (is_array($item)) {
363  // Expand CKEditor4 preset toolbar groups
364  if (is_string($item['name'] ?? null) && count($item) === 1 && isset(self::TOOLBAR_MAIN_GROUPS_MAP[$item['name']])) {
365  $item['groups'] = self::TOOLBAR_MAIN_GROUPS_MAP[$item['name']];
366  }
367  // Flatten CKEditor4 arrays that only have strings assigned
368  if (count($item) === count(array_filter($item, static fn(mixed $value): bool => is_string($value)))) {
369  $migratedToolbarItems = $item;
370  $migratedToolbarItems = $this->‪migrateToolbarButtons($migratedToolbarItems);
371  $migratedToolbarItems = $this->‪migrateToolbarSpacers($migratedToolbarItems);
372  array_push($toolbarItems, ...$migratedToolbarItems);
373  $toolbarItems[] = '|';
374  continue;
375  }
376  // Flatten CKEditor4 named groups
377  if (is_string($item['name'] ?? null) && is_array($item['items'] ?? null)) {
378  $migratedToolbarItems = $item['items'];
379  $migratedToolbarItems = $this->‪migrateToolbarButtons($migratedToolbarItems);
380  $migratedToolbarItems = $this->‪migrateToolbarSpacers($migratedToolbarItems);
381  array_push($toolbarItems, ...$migratedToolbarItems);
382  $toolbarItems[] = '|';
383  continue;
384  }
385  // Expand CKEditor4 toolbar groups
386  if (is_string($item['name'] ?? null) && is_array($item['groups'] ?? null)) {
387  $itemGroups = array_filter($item['groups'], static fn(mixed $itemGroup): bool => is_string($itemGroup));
388 
389  // Process Main CKEditor4 Groups
390  $unGroupedToolbarItems = [];
391  foreach ($itemGroups as $itemGroup) {
392  if (isset(self::TOOLBAR_MAIN_GROUPS_MAP[$itemGroup])) {
393  array_push($unGroupedToolbarItems, ...self::TOOLBAR_MAIN_GROUPS_MAP[$itemGroup]);
394  $unGroupedToolbarItems[] = '|';
395  continue;
396  }
397  $unGroupedToolbarItems[] = $itemGroup;
398  }
399 
400  // Process CKEditor4 Groups
401  $groupedToolbarItems = [];
402  foreach ($itemGroups as $itemGroup) {
403  if (isset(self::TOOLBAR_GROUPS_MAP[$itemGroup])) {
404  array_push($groupedToolbarItems, ...self::TOOLBAR_GROUPS_MAP[$itemGroup]);
405  $groupedToolbarItems[] = '|';
406  continue;
407  }
408  $groupedToolbarItems[] = $itemGroup;
409  }
410 
411  $migratedToolbarItems = $groupedToolbarItems;
412  $migratedToolbarItems = $this->‪migrateToolbarButtons($migratedToolbarItems);
413  $migratedToolbarItems = $this->‪migrateToolbarSpacers($migratedToolbarItems);
414  array_push($toolbarItems, ...$migratedToolbarItems);
415  $toolbarItems[] = '|';
416  continue;
417  }
418 
419  $toolbarItems[] = $item;
420  }
421  }
422 
423  $toolbarItems = $this->‪migrateToolbarLinebreaks($toolbarItems);
424  $toolbarItems = $this->‪migrateToolbarCleanup($toolbarItems);
425 
426  return array_values($toolbarItems);
427  }
428 
429  protected function ‪migrateToolbarButton(string $buttonName): ?string
430  {
431  if (array_key_exists($buttonName, self::BUTTON_MAP)) {
432  return self::BUTTON_MAP[$buttonName];
433  }
434  return $buttonName;
435  }
436 
437  protected function ‪migrateToolbarButtons(array $toolbarItems): array
438  {
439  $processedItems = [];
440  foreach ($toolbarItems as $toolbarItem) {
441  if (is_string($toolbarItem)) {
442  if (($toolbarItem = $this->‪migrateToolbarButton($toolbarItem)) !== null) {
443  $processedItems[] = $this->‪migrateToolbarButton($toolbarItem);
444  }
445  } else {
446  $processedItems[] = $toolbarItem;
447  }
448  }
449 
450  return $processedItems;
451  }
452 
453  protected function ‪migrateToolbarSpacers(array $toolbarItems): array
454  {
455  $processedItems = [];
456  foreach ($toolbarItems as $toolbarItem) {
457  if (is_string($toolbarItem)) {
458  $toolbarItem = str_replace('-', '|', $toolbarItem);
459  }
460  $processedItems[] = $toolbarItem;
461  }
462 
463  return $processedItems;
464  }
465 
466  protected function ‪migrateToolbarLinebreaks(array $toolbarItems): array
467  {
468  $processedItems = [];
469  foreach ($toolbarItems as $toolbarItem) {
470  if (is_string($toolbarItem)) {
471  $toolbarItem = str_replace('/', '-', $toolbarItem);
472  }
473  $processedItems[] = $toolbarItem;
474  }
475 
476  return $processedItems;
477  }
478 
479  protected function ‪migrateToolbarCleanup(array $toolbarItems): array
480  {
481  // Ensure buttons are only added once to the toolbar.
482  $searchValues = [];
483  foreach ($toolbarItems as $toolbarKey => $toolbarItem) {
484  if (is_string($toolbarItem) && !in_array($toolbarItem, ['|', '-'])) {
485  if (array_key_exists($toolbarItem, $searchValues)) {
486  unset($toolbarItems[$toolbarKey]);
487  } else {
488  $searchValues[$toolbarItem] = true;
489  }
490  }
491  }
492 
493  $previousItem = null;
494  $previousKey = null;
495  foreach ($toolbarItems as $toolbarKey => $toolbarItem) {
496  if ($previousItem === null && ($toolbarItem === '|' || $toolbarItem === '-')) {
497  unset($toolbarItems[$toolbarKey]);
498  continue;
499  }
500 
501  if ($previousItem === '|' && ($toolbarItem === '|' || $toolbarItem === '-')) {
502  unset($toolbarItems[$previousKey]);
503  }
504 
505  $previousKey = $toolbarKey;
506  $previousItem = $toolbarItem;
507  }
508 
509  $lastToolbarItem = array_slice($toolbarItems, -1, 1);
510  if ($lastToolbarItem === ['-'] || $lastToolbarItem === ['|']) {
511  array_pop($toolbarItems);
512  }
513 
514  return array_values($toolbarItems);
515  }
516 
517  protected function ‪migrateRemoveButtonsFromToolbar(): void
518  {
519  if (!isset($this->configuration['editor']['config']['removeButtons'])) {
520  return;
521  }
522 
523  if (is_string($this->configuration['editor']['config']['removeButtons'])) {
524  $this->configuration['editor']['config']['removeButtons'] = GeneralUtility::trimExplode(
525  ',',
526  $this->configuration['editor']['config']['removeButtons'],
527  true
528  );
529  }
530 
531  $removeItems = [];
532  foreach ($this->configuration['editor']['config']['removeButtons'] as $buttonName) {
533  if (array_key_exists($buttonName, self::BUTTON_MAP)) {
534  if (self::BUTTON_MAP[$buttonName] !== null) {
535  $removeItems[] = self::BUTTON_MAP[$buttonName];
536  }
537  } else {
538  $removeItems[] = $buttonName;
539  }
540  }
541 
542  foreach ($removeItems as $name) {
543  $this->‪removeToolbarItem($name);
544  }
545 
546  // Cleanup final configuration after migration
547  unset($this->configuration['editor']['config']['removeButtons']);
548  }
549 
550  protected function ‪migrateFormatTagsToHeadings(): void
551  {
552  // new definition is in place, no migration is done
553  if (isset($this->configuration['editor']['config']['heading']['options'])) {
554  // discard legacy configuration if new configuration exists
555  unset($this->configuration['editor']['config']['format_tags']);
556  return;
557  }
558  // migrate format_tags to custom buttons
559  if (isset($this->configuration['editor']['config']['format_tags'])) {
560  $formatTags = explode(';', $this->configuration['editor']['config']['format_tags']);
561  $allowedHeadings = [];
562  foreach ($formatTags as $paragraphTag) {
563  switch (strtolower($paragraphTag)) {
564  case 'p':
565  $allowedHeadings[] = [
566  'model' => 'paragraph',
567  'title' => 'Paragraph',
568  ];
569  break;
570  case 'h1':
571  case 'h2':
572  case 'h3':
573  case 'h4':
574  case 'h5':
575  case 'h6':
576  $headingNumber = substr($paragraphTag, -1);
577  $allowedHeadings[] = [
578  'model' => 'heading' . $headingNumber,
579  'view' => 'h' . $headingNumber,
580  'title' => 'Heading ' . $headingNumber,
581  ];
582  break;
583  case 'pre':
584  $allowedHeadings[] = [
585  'model' => 'formatted',
586  'view' => 'pre',
587  'title' => 'Formatted',
588  ];
589  }
590  }
591 
592  // remove legacy configuration after migration
593  unset($this->configuration['editor']['config']['format_tags']);
594  $this->configuration['editor']['config']['heading']['options'] = $allowedHeadings;
595  }
596  }
597 
598  protected function ‪migrateStylesSetToStyleDefinitions(): void
599  {
600  // new definition is in place, no migration is done
601  if (isset($this->configuration['editor']['config']['style']['definitions'])) {
602  // discard legacy configuration if new configuration exists
603  unset($this->configuration['editor']['config']['stylesSet']);
604  return;
605  }
606  // Migrate 'stylesSet' to 'styles' => 'definitions'
607  if (isset($this->configuration['editor']['config']['stylesSet'])) {
608  $styleDefinitions = [];
609  foreach ($this->configuration['editor']['config']['stylesSet'] as $styleSet) {
610  if (!isset($styleSet['name'], $styleSet['element'])) {
611  // @todo: log
612  continue;
613  }
614  $class = $styleSet['attributes']['class'] ?? null;
615  $definition = [
616  'name' => $styleSet['name'],
617  'element' => $styleSet['element'],
618  'classes' => [''],
619  ];
620  if ($class) {
621  $definition['classes'] = explode(' ', $class);
622  }
623  $styleDefinitions[] = $definition;
624  }
625 
626  // remove legacy configuration after migration
627  unset($this->configuration['editor']['config']['stylesSet']);
628  $this->configuration['editor']['config']['style']['definitions'] = $styleDefinitions;
629  }
630  }
631 
632  protected function ‪migrateContentsCssToArray(): void
633  {
634  if (isset($this->configuration['editor']['config']['contentsCss'])) {
635  if (!is_array($this->configuration['editor']['config']['contentsCss'])) {
636  if (empty($this->configuration['editor']['config']['contentsCss'])) {
637  unset($this->configuration['editor']['config']['contentsCss']);
638  return;
639  }
640  $this->configuration['editor']['config']['contentsCss'] = (array)$this->configuration['editor']['config']['contentsCss'];
641  }
642 
643  $this->configuration['editor']['config']['contentsCss'] = array_map(static function (mixed $styleSrc): mixed {
644  // Trim values, if input is a string, otherwise leave as-is (will be filtered out)
645  return is_string($styleSrc) ? trim($styleSrc) : $styleSrc;
646  }, $this->configuration['editor']['config']['contentsCss']);
647  $this->configuration['editor']['config']['contentsCss'] = array_values(
648  array_filter($this->configuration['editor']['config']['contentsCss'], static function (mixed $styleSrc): bool {
649  // We care for non-empty strings only
650  return is_string($styleSrc) && $styleSrc !== '';
651  })
652  );
653  }
654  }
655 
656  protected function ‪migrateTypo3LinkAdditionalAttributes(): void
657  {
658  if (!isset($this->configuration['editor']['config']['typo3link']['additionalAttributes'])) {
659  return;
660  }
661  $additionalAttributes = $this->configuration['editor']['config']['typo3link']['additionalAttributes'];
662  unset($this->configuration['editor']['config']['typo3link']['additionalAttributes']);
663  if ($this->configuration['editor']['config']['typo3link'] === []) {
664  unset($this->configuration['editor']['config']['typo3link']);
665  }
666  if (!is_array($additionalAttributes) || $additionalAttributes === []) {
667  return;
668  }
669  $this->configuration['editor']['config']['htmlSupport']['allow'][] = [
670  'name' => 'a',
671  'attributes' => array_values($additionalAttributes),
672  ];
673  }
674 
675  protected function ‪parseRuleProperties(string $properties, string $type): ?string
676  {
677  $groupsPatterns = [
678  'styles' => '/{([^}]+)}/',
679  'attrs' => '/\[([^\]]+)\]/',
680  'classes' => '/\‍(([^\‍)]+)\‍)/',
681  ];
682  $pattern = $groupsPatterns[$type] ?? null;
683  if ($pattern === null) {
684  throw new \InvalidArgumentException('Expected type to be styles, attrs or classes', 1696326899);
685  }
686 
687  $matches = [];
688  if (preg_match($pattern, $properties, $matches) === 1) {
689  return trim($matches[1]);
690  }
691 
692  return null;
693  }
694 
698  protected function ‪parseRulesString(string $input): array
699  {
700  $ruleConfig = [];
701  do {
702  $matches = [];
703  $res = preg_match(
704  // Based on https://github.com/ckeditor/ckeditor4/blob/4.23.0-lts/core/filter.js#L1431
705  // < elements >< styles, attributes and classes >< separator >
706  '/^([a-z0-9\-*\s]+)((?:\s*\{[!\w\-,\s\*]+\}\s*|\s*\[[!\w\-,\s\*]+\]\s*|\s*\‍([!\w\-,\s\*]+\‍)\s*){0,3})(?:;\s*|$)/i',
707  $input,
708  $matches
709  );
710  if ($res === false || $res === 0) {
711  return $ruleConfig;
712  }
713  $name = $matches[1];
714  $properties = $matches[2] ?? null;
715  $config = true;
716  if ($properties !== null) {
717  $config = [];
718  $config['styles'] = $this->‪parseRuleProperties($properties, 'styles');
719  $config['attributes'] = $this->‪parseRuleProperties($properties, 'attrs');
720  $config['classes'] = $this->‪parseRuleProperties($properties, 'classes');
721  }
722  $ruleConfig[$name] = $config;
723 
724  $input = substr($input, strlen($matches[0]));
725  } while ($input !== '');
726  return $ruleConfig;
727  }
728 
729  protected function ‪migrateAllowedContent(): void
730  {
731  $types = [
732  'allowedContent' => 'allow',
733  'extraAllowedContent' => 'allow',
734  'disallowedContent' => 'disallow',
735  ];
736 
737  foreach ($types as $option4 => $option5) {
738  if (!isset($this->configuration['editor']['config'][$option4])) {
739  continue;
740  }
741 
742  if ($option4 === 'allowedContent') {
743  if ($this->configuration['editor']['config']['allowedContent'] === true || $this->configuration['editor']['config']['allowedContent'] === '1') {
744  $this->configuration['editor']['config']['htmlSupport']['allow'][] = [
745  // Allow *any* tag (even custom elements)
746  'name' => [
747  'pattern' => '.+',
748  ],
749  'attributes' => true,
750  'classes' => true,
751  'styles' => true,
752  ];
753  unset($this->configuration['editor']['config']['allowedContent']);
754  continue;
755  }
756  }
757 
758  $config4 = $this->configuration['editor']['config'][$option4];
759  if (is_string($config4)) {
760  $config4 = $this->‪parseRulesString($config4);
761  }
762 
763  foreach ($config4 as $name => $options) {
764  $config = [];
765  if ($name !== '*') {
766  $name = (string)$name;
767  $config['name'] = str_contains($name, '*') || str_contains($name, ' ') ?
768  [ 'pattern' => str_replace(['*', ' '], ['.+', '|'], $name) ] :
769  $name;
770  }
771 
772  if (is_bool($options)) {
773  if ($options) {
774  $this->configuration['editor']['config']['htmlSupport'][$option5][] = $config;
775  }
776  continue;
777  }
778 
779  if (!is_array($options)) {
780  continue;
781  }
782 
783  $wildcardToRegex = fn(string $v): string|array => str_contains($v, '*') ? [ 'pattern' => str_replace('*', '.+', $v) ] : $v;
784  if (isset($options['classes'])) {
785  if ($options['classes'] === '*') {
786  $config['classes'] = true;
787  } else {
788  $config['classes'] = array_map($wildcardToRegex, explode(',', $options['classes']));
789  }
790  }
791 
792  if (isset($options['attributes'])) {
793  if ($options['attributes'] === '*') {
794  $config['attributes'] = true;
795  } else {
796  $config['attributes'] = array_map($wildcardToRegex, explode(',', $options['attributes']));
797  }
798  }
799 
800  if (isset($options['styles'])) {
801  if ($options['styles'] === '*') {
802  $config['styles'] = true;
803  } else {
804  $config['styles'] = array_map($wildcardToRegex, explode(',', $options['styles']));
805  }
806  }
807  $this->configuration['editor']['config']['htmlSupport'][$option5][] = $config;
808  }
809  unset($this->configuration['editor']['config'][$option4]);
810  }
811  }
812 
813  protected function ‪handleAlignmentPlugin(): void
814  {
815  // Migrate legacy configuration
816  // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-justifyClasses
817  if (isset($this->configuration['editor']['config']['justifyClasses'])) {
818  if (!isset($this->configuration['editor']['config']['alignment'])) {
819  $legacyConfig = $this->configuration['editor']['config']['justifyClasses'];
820  $indexMap = [
821  0 => 'left',
822  1 => 'center',
823  2 => 'right',
824  3 => 'justify',
825  ];
826  foreach ($legacyConfig as $index => $class) {
827  $itemConfig = [];
828  if (isset($indexMap[$index])) {
829  $itemConfig['name'] = $indexMap[$index];
830  }
831  $itemConfig['className'] = $class;
832  $this->configuration['editor']['config']['alignment']['options'][] = $itemConfig;
833  }
834  }
835  unset($this->configuration['editor']['config']['justifyClasses']);
836  }
837  $this->‪removeExtraPlugin('justify');
838 
839  // Remove related configuration if plugin should not be loaded
840  if (in_array(
841  '@ckeditor/ckeditor5-alignment',
842  array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'),
843  true
844  )) {
845  // Remove toolbar items
846  $this->‪removeToolbarItem('alignment');
847  $this->‪removeToolbarItem('alignment:left');
848  $this->‪removeToolbarItem('alignment:right');
849  $this->‪removeToolbarItem('alignment:center');
850  $this->‪removeToolbarItem('alignment:justify');
851 
852  // Remove config
853  if (isset($this->configuration['editor']['config']['alignment'])) {
854  unset($this->configuration['editor']['config']['alignment']);
855  }
856 
857  return;
858  }
859 
860  if (is_array($this->configuration['editor']['config']['alignment']['options'] ?? null)) {
861  $classMap = [];
862  foreach ($this->configuration['editor']['config']['alignment']['options'] as $option) {
863  if (is_string($option['name'] ?? null)
864  && is_string($option['className'] ?? null)
865  && in_array($option['name'], ['left', 'center', 'right', 'justify'])) {
866  $classMap[$option['name']] = $option['className'];
867  }
868  }
869  }
870 
871  // Default config
872  $this->configuration['editor']['config']['alignment'] = [
873  'options' => [
874  ['name' => 'left', 'className' => $classMap['left'] ?? 'text-start'],
875  ['name' => 'center', 'className' => $classMap['center'] ?? 'text-center'],
876  ['name' => 'right', 'className' => $classMap['right'] ?? 'text-end'],
877  ['name' => 'justify', 'className' => $classMap['justify'] ?? 'text-justify'],
878  ],
879  ];
880  }
881 
882  protected function ‪handleWhitespacePlugin(): void
883  {
884  // Remove related configuration if plugin should not be loaded
885  if (in_array(
886  '@typo3/rte-ckeditor/plugin/whitespace.js',
887  array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'),
888  true
889  )) {
890  // Remove toolbar items
891  $this->‪removeToolbarItem('softhyphen');
892 
893  return;
894  }
895 
896  // Add button if missing
897  if (!in_array('softhyphen', $this->configuration['editor']['config']['toolbar']['items'], true)) {
898  $this->configuration['editor']['config']['toolbar']['items'][] = 'softhyphen';
899  }
900  }
901 
902  protected function ‪handleWordCountPlugin(): void
903  {
904  // Migrate legacy configuration
905  //
906  // CKEditor4 used `wordcount` (lowercase), which is `wordCount` in CKEditor5.
907  // The amount of properties has been reduced.
908  //
909  // see https://ckeditor.com/docs/ckeditor5/latest/features/word-count.html
910  if (isset($this->configuration['editor']['config']['wordcount'])) {
911  if (!isset($this->configuration['editor']['config']['wordCount'])) {
912  $legacyConfig = $this->configuration['editor']['config']['wordcount'];
913  if (isset($legacyConfig['showCharCount'])) {
914  $this->configuration['editor']['config']['wordCount']['displayCharacters'] = !empty($legacyConfig['showCharCount']);
915  }
916  if (isset($legacyConfig['showWordCount'])) {
917  $this->configuration['editor']['config']['wordCount']['displayWords'] = !empty($legacyConfig['showWordCount']);
918  }
919  }
920  unset($this->configuration['editor']['config']['wordcount']);
921  }
922 
923  // Remove related configuration if plugin should not be loaded
924  if (in_array(
925  '@ckeditor/ckeditor5-word-count',
926  array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'),
927  true
928  )) {
929  // Remove config
930  if (isset($this->configuration['editor']['config']['wordCount'])) {
931  unset($this->configuration['editor']['config']['wordCount']);
932  }
933 
934  return;
935  }
936 
937  // Default config
938  $this->configuration['editor']['config']['wordCount'] = [
939  'displayCharacters' => $this->configuration['editor']['config']['wordCount']['displayCharacters'] ?? true,
940  'displayWords' => $this->configuration['editor']['config']['wordCount']['displayWords'] ?? true,
941  ];
942  }
943 
944  protected function ‪addLinkClassesToStyleSets(): void
945  {
946  if (!isset($this->configuration['buttons']['link']['properties']['class']['allowedClasses'])) {
947  return;
948  }
949 
950  // Ensure editor.config.style.definitions exists
951  $this->configuration['editor']['config']['style']['definitions'] ??= [];
952 
953  $allowedClassSets = is_array($this->configuration['buttons']['link']['properties']['class']['allowedClasses'])
954  ? $this->configuration['buttons']['link']['properties']['class']['allowedClasses']
955  : GeneralUtility::trimExplode(',', $this->configuration['buttons']['link']['properties']['class']['allowedClasses'], true);
956 
957  // Determine index where link classes should be added at to keep styles grouped
958  $indexToInsertElementsAt = array_key_last($this->configuration['editor']['config']['style']['definitions']) + 1;
959  foreach ($this->configuration['editor']['config']['style']['definitions'] as $index => $styleSetDefinition) {
960  if ($styleSetDefinition['element'] === 'a') {
961  $indexToInsertElementsAt = $index + 1;
962  }
963  }
964 
965  foreach ($allowedClassSets as $classSet) {
966  $allowedClasses = GeneralUtility::trimExplode(' ', $classSet);
967  foreach ($this->configuration['editor']['config']['style']['definitions'] as $styleSetDefinition) {
968  if ($styleSetDefinition['element'] === 'a' && $styleSetDefinition['classes'] === $allowedClasses) {
969  // allowedClasses is already configured, continue with next one
970  continue 2;
971  }
972  }
973 
974  // We're still here, this means $allowedClasses wasn't found
975  array_splice($this->configuration['editor']['config']['style']['definitions'], $indexToInsertElementsAt, 0, [[
976  'classes' => $allowedClasses,
977  'element' => 'a',
978  'name' => implode(' ', $allowedClasses), // we lack a human-readable name here...
979  ]]);
980  $indexToInsertElementsAt++;
981  }
982  }
983 
984  private function ‪removeToolbarItem(string $name): void
985  {
986  $this->configuration['editor']['config']['toolbar']['removeItems'][] = $name;
987  $this->configuration['editor']['config']['toolbar']['removeItems'] = $this->‪getUniqueArrayValues($this->configuration['editor']['config']['toolbar']['removeItems']);
988  }
989 
990  private function ‪removeExtraPlugin(string $name): void
991  {
992  if (!isset($this->configuration['editor']['config']['extraPlugins'])) {
993  return;
994  }
995 
996  $this->configuration['editor']['config']['extraPlugins'] = array_filter($this->configuration['editor']['config']['extraPlugins'], static function (string $value) use ($name) {
997  return $value !== $name;
998  });
999 
1000  if (empty($this->configuration['editor']['config']['extraPlugins'])) {
1001  unset($this->configuration['editor']['config']['extraPlugins']);
1002  return;
1003  }
1004 
1005  $this->configuration['editor']['config']['extraPlugins'] = $this->‪getUniqueArrayValues($this->configuration['editor']['config']['extraPlugins']);
1006  }
1007 
1012  private function ‪getUniqueArrayValues(array $array)
1013  {
1014  return array_values(array_unique($array));
1015  }
1016 }
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarSpacers
‪migrateToolbarSpacers(array $toolbarItems)
Definition: CKEditor5Migrator.php:453
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\handleWordCountPlugin
‪handleWordCountPlugin()
Definition: CKEditor5Migrator.php:902
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarLinebreaks
‪migrateToolbarLinebreaks(array $toolbarItems)
Definition: CKEditor5Migrator.php:466
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateAllowedContent
‪migrateAllowedContent()
Definition: CKEditor5Migrator.php:729
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarButtons
‪migrateToolbarButtons(array $toolbarItems)
Definition: CKEditor5Migrator.php:437
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\removeToolbarItem
‪removeToolbarItem(string $name)
Definition: CKEditor5Migrator.php:984
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateExtraPlugins
‪migrateExtraPlugins()
Definition: CKEditor5Migrator.php:273
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateRemovePlugins
‪migrateRemovePlugins()
Definition: CKEditor5Migrator.php:289
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\parseRuleProperties
‪parseRuleProperties(string $properties, string $type)
Definition: CKEditor5Migrator.php:675
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarItems
‪migrateToolbarItems(array $items)
Definition: CKEditor5Migrator.php:354
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\__construct
‪__construct(protected array $configuration)
Definition: CKEditor5Migrator.php:242
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateRemoveButtonsFromToolbar
‪migrateRemoveButtonsFromToolbar()
Definition: CKEditor5Migrator.php:517
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\addLinkClassesToStyleSets
‪addLinkClassesToStyleSets()
Definition: CKEditor5Migrator.php:944
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\TOOLBAR_MAIN_GROUPS_MAP
‪const TOOLBAR_MAIN_GROUPS_MAP
Definition: CKEditor5Migrator.php:31
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarButton
‪migrateToolbarButton(string $buttonName)
Definition: CKEditor5Migrator.php:429
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateStylesSetToStyleDefinitions
‪migrateStylesSetToStyleDefinitions()
Definition: CKEditor5Migrator.php:598
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\removeExtraPlugin
‪removeExtraPlugin(string $name)
Definition: CKEditor5Migrator.php:990
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateTypo3LinkAdditionalAttributes
‪migrateTypo3LinkAdditionalAttributes()
Definition: CKEditor5Migrator.php:656
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\getUniqueArrayValues
‪getUniqueArrayValues(array $array)
Definition: CKEditor5Migrator.php:1012
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\handleAlignmentPlugin
‪handleAlignmentPlugin()
Definition: CKEditor5Migrator.php:813
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateContentsCssToArray
‪migrateContentsCssToArray()
Definition: CKEditor5Migrator.php:632
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\PLUGIN_MAP
‪const PLUGIN_MAP
Definition: CKEditor5Migrator.php:180
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\TOOLBAR_GROUPS_MAP
‪const TOOLBAR_GROUPS_MAP
Definition: CKEditor5Migrator.php:53
‪TYPO3\CMS\Core\Configuration
Definition: CKEditor5Migrator.php:18
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator
Definition: CKEditor5Migrator.php:26
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\BUTTON_MAP
‪const BUTTON_MAP
Definition: CKEditor5Migrator.php:83
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:52
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\parseRulesString
‪parseRulesString(string $input)
Definition: CKEditor5Migrator.php:698
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateFormatTagsToHeadings
‪migrateFormatTagsToHeadings()
Definition: CKEditor5Migrator.php:550
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarCleanup
‪migrateToolbarCleanup(array $toolbarItems)
Definition: CKEditor5Migrator.php:479
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\handleWhitespacePlugin
‪handleWhitespacePlugin()
Definition: CKEditor5Migrator.php:882