commit 1e393722e8089082cc625fd9ee0ebb5df58370b5 Author: Sven Wappler Date: Mon Aug 10 22:31:36 2026 +0200 TYPO3 v15 dev-main snapshot () diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/Classes/Configuration/CKEditor5Migrator.php b/Classes/Configuration/CKEditor5Migrator.php new file mode 100644 index 0000000..44391a6 --- /dev/null +++ b/Classes/Configuration/CKEditor5Migrator.php @@ -0,0 +1,1051 @@ + ['mode', 'document', 'doctools'], + 'clipboard' => ['clipboard', 'undo'], + 'editing' => ['find', 'selection', 'spellchecker', 'editing'], + 'forms' => ['forms'], + 'basicstyles' => ['basicstyles', 'cleanup'], + 'paragraph' => ['list', 'indent', 'blocks', 'align', 'bidi', 'paragraph'], + 'links' => ['links'], + 'insert' => ['insert'], + 'styles' => ['styles'], + 'colors' => ['colors'], + 'tools' => ['tools'], + 'others' => ['others'], + 'about' => ['about'], + 'blocks' => ['blocks'], + 'table' => ['table'], + 'tabletools' => [], + ]; + + /** + * Groups in CKEditor 4 contain buttons. + */ + private const array TOOLBAR_GROUPS_MAP = [ + 'mode' => ['Source'], + 'document' => ['Save', 'NewPage', 'Preview', 'Print'], + 'doctools' => ['Templates'], + 'clipboard' => ['Cut', 'Copy', 'Paste', 'PasteText', 'PasteFromWord'], + 'undo' => ['Undo', 'Redo'], + 'find' => ['Find', 'Replace'], + 'selection' => ['SelectAll'], + 'spellchecker' => ['Scayt'], + 'forms' => ['Form', 'Checkbox', 'Radio', 'TextField', 'Textarea', 'Select', 'Button', 'ImageButton', 'HiddenField'], + 'basicstyles' => ['Bold', 'Italic', 'Underline', 'Strike', 'Subscript', 'Superscript', 'SoftHyphen'], + 'cleanup' => ['CopyFormatting', 'RemoveFormat'], + 'list' => ['NumberedList', 'BulletedList'], + 'indent' => ['Indent', 'Outdent'], + 'blocks' => ['Blockquote', 'CreateDiv'], + 'align' => ['JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock'], + 'bidi' => ['BidiLtr', 'BidiRtl', 'Language'], + 'links' => ['Link', 'Unlink', 'Anchor'], + 'insert' => ['Image', 'Flash', 'Table', 'HorizontalRule', 'Smiley', 'SpecialChar', 'PageBreak', 'Iframe'], + 'styles' => ['Styles', 'Format', 'Font', 'FontSize'], + 'format' => ['Format'], + 'table' => ['Table'], + 'specialchar' => ['SpecialChar'], + 'colors' => ['TextColor', 'BGColor'], + 'tools' => ['Maximize', 'ShowBlocks'], + 'about' => ['About'], + 'others' => [], + ]; + + // List of "old" button names vs the replacement(s) + private const array BUTTON_MAP = [ + // mode + 'Source' => 'sourceEditing', + // document + 'Save' => null, + 'NewPage' => null, + 'Preview' => null, + 'Print' => null, + // doctools + 'Templates' => null, + // clipboard + 'Cut' => null, + 'Copy' => null, + 'Paste' => null, + 'PasteText' => null, + 'PasteFromWord' => null, + // undo + 'Undo' => 'undo', + 'Redo' => 'redo', + // find + 'Find' => null, + 'Replace' => 'findAndReplace', + // selection + 'SelectAll' => 'selectAll', + // spellchecker + 'Scayt' => null, + // forms + 'Form' => null, + 'Checkbox' => null, + 'Radio' => null, + 'TextField' => null, + 'Textarea' => null, + 'Select' => null, + 'Button' => null, + 'ImageButton' => null, + 'HiddenField' => null, + // basicstyles + 'Bold' => 'bold', + 'Italic' => 'italic', + 'Underline' => 'underline', + 'Strike' => 'strikethrough', + 'Subscript' => 'subscript', + 'Superscript' => 'superscript', + // cleanup + 'CopyFormatting' => null, + 'RemoveFormat' => 'removeFormat', + // list + 'NumberedList' => 'numberedList', + 'BulletedList' => 'bulletedList', + // indent + 'Outdent' => 'outdent', + 'Indent' => 'indent', + // blocks + 'Blockquote' => 'blockQuote', + 'CreateDiv' => null, + // align + 'JustifyLeft' => 'alignment:left', + 'JustifyCenter' => 'alignment:center', + 'JustifyRight' => 'alignment:right', + 'JustifyBlock' => 'alignment:justify', + // bidi + 'BidiLtr' => null, + 'BidiRtl' => null, + 'Language' => 'textPartLanguage', + // links + 'Link' => 'link', + 'Unlink' => null, + 'Anchor' => null, + // insert + 'Image' => 'insertImage', + 'Flash' => null, + 'Table' => 'insertTable', + 'HorizontalRule' => 'horizontalLine', + 'Smiley' => null, + 'SpecialChar' => 'specialCharacters', + 'PageBreak' => 'pageBreak', + 'Iframe' => null, + // styles + 'Styles' => 'style', + 'Format' => 'heading', + 'Font' => 'fontFamily', + 'FontSize' => 'fontSize', + // colors + 'TextColor' => 'fontColor', + 'BGColor' => 'fontBackgroundColor', + // tools + 'Maximize' => null, + 'ShowBlocks' => null, + // about + 'About' => null, + // typo3 + 'SoftHyphen' => 'softhyphen', + ]; + + /** + * Mapping of plugins + */ + private const array PLUGIN_MAP = [ + 'image' => [ + 'module' => '@ckeditor/ckeditor5-image', + 'exports' => [ 'Image', 'ImageCaption', 'ImageStyle', 'ImageToolbar', 'ImageUpload', 'PictureEditing' ], + ], + 'Image' => [ + 'module' => '@ckeditor/ckeditor5-image', + 'exports' => [ 'Image', 'ImageCaption', 'ImageStyle', 'ImageToolbar', 'ImageUpload', 'PictureEditing' ], + ], + 'alignment' => [ + 'module' => '@ckeditor/ckeditor5-alignment', + 'exports' => [ 'Alignment' ], + ], + 'Alignment' => [ + 'module' => '@ckeditor/ckeditor5-alignment', + 'exports' => [ 'Alignment' ], + ], + 'autolink' => [ + 'module' => '@ckeditor/ckeditor5-link', + 'exports' => [ 'AutoLink' ], + ], + 'AutoLink' => [ + 'module' => '@ckeditor/ckeditor5-link', + 'exports' => [ 'AutoLink' ], + ], + 'font' => [ + 'module' => '@ckeditor/ckeditor5-font', + 'exports' => [ 'Font' ], + ], + 'Font' => [ + 'module' => '@ckeditor/ckeditor5-font', + 'exports' => [ 'Font' ], + ], + 'justify' => [ + 'module' => '@ckeditor/ckeditor5-alignment', + 'exports' => [ 'Alignment' ], + ], + 'showblocks' => [ + 'module' => '@ckeditor/ckeditor5-show-blocks', + 'exports' => [ 'ShowBlocks' ], + ], + 'ShowBlocks' => [ + 'module' => '@ckeditor/ckeditor5-show-blocks', + 'exports' => [ 'ShowBlocks' ], + ], + 'softhyphen' => [ + 'module' => '@typo3/rte-ckeditor/plugin/whitespace.js', + 'exports' => [ 'Whitespace' ], + ], + 'whitespace' => [ + 'module' => '@typo3/rte-ckeditor/plugin/whitespace.js', + 'exports' => [ 'Whitespace' ], + ], + 'Whitespace' => [ + 'module' => '@typo3/rte-ckeditor/plugin/whitespace.js', + 'exports' => [ 'Whitespace' ], + ], + 'wordcount' => [ + 'module' => '@ckeditor/ckeditor5-word-count', + 'exports' => [ 'WordCount' ], + ], + 'WordCount' => [ + 'module' => '@ckeditor/ckeditor5-word-count', + 'exports' => [ 'WordCount' ], + ], + ]; + + /** + * @param array $configuration Richtext configuration + */ + public function __construct(protected array $configuration) + { + if (isset($this->configuration['editor']['config'])) { + $this->migrateExtraPlugins(); + $this->migrateRemovePlugins(); + $this->migrateToolbar(); + $this->migrateRemoveButtonsFromToolbar(); + $this->migrateFormatTagsToHeadings(); + $this->migrateStylesSetToStyleDefinitions(); + $this->migrateContentsCssToArray(); + $this->migrateTypo3LinkAdditionalAttributes(); + $this->migrateAllowedContent(); + // configure plugins + $this->handleAlignmentPlugin(); + $this->handleWhitespacePlugin(); + $this->handleWordCountPlugin(); + $this->handleStyleDefinitions(); + + // sort by key + ksort($this->configuration['editor']['config']); + } + + if (isset($this->configuration['buttons']['link'])) { + $this->addLinkClassesToStyleSets(); + } + } + + public function get(): array + { + return $this->configuration; + } + + protected function migrateExtraPlugins(): void + { + if (!isset($this->configuration['editor']['config']['extraPlugins'])) { + return; + } + + if (is_string($this->configuration['editor']['config']['extraPlugins'])) { + $this->configuration['editor']['config']['extraPlugins'] = GeneralUtility::trimExplode( + ',', + $this->configuration['editor']['config']['extraPlugins'], + true + ); + } + + foreach ($this->configuration['editor']['config']['extraPlugins'] as $entry) { + $moduleToBeLoaded = self::PLUGIN_MAP[$entry] ?? null; + if ($moduleToBeLoaded === null) { + continue; + } + $this->configuration['editor']['config']['importModules'][] = $moduleToBeLoaded; + $this->removeExtraPlugin($entry); + } + } + + protected function migrateRemovePlugins(): void + { + if (!isset($this->configuration['editor']['config']['removePlugins'])) { + return; + } + + if (is_string($this->configuration['editor']['config']['removePlugins'])) { + $this->configuration['editor']['config']['removePlugins'] = GeneralUtility::trimExplode( + ',', + $this->configuration['editor']['config']['removePlugins'], + true + ); + } + + foreach ($this->configuration['editor']['config']['removePlugins'] as $key => $entry) { + $moduleToBeRemoved = self::PLUGIN_MAP[$entry] ?? null; + if ($moduleToBeRemoved !== null) { + unset($this->configuration['editor']['config']['removePlugins'][$key]); + $this->configuration['editor']['config']['removeImportModules'][] = $moduleToBeRemoved; + } + } + if (count($this->configuration['editor']['config']['removePlugins']) === 0) { + unset($this->configuration['editor']['config']['removePlugins']); + } else { + $this->configuration['editor']['config']['removePlugins'] = $this->getUniqueArrayValues($this->configuration['editor']['config']['removePlugins']); + } + } + + /** + * CE4: https://ckeditor.com/latest/samples/toolbarconfigurator/index.html#basic + * CE5: https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html#extended-toolbar-configuration-format + */ + protected function migrateToolbar(): void + { + /** + * Collection of the final toolbar configuration + * @var array{items: string[], removeItems: string[], shouldNotGroupWhenFull: bool} $toolbar + */ + $toolbar = [ + 'items' => [], + 'removeItems' => $this->configuration['editor']['config']['toolbar']['removeItems'] ?? [], + 'shouldNotGroupWhenFull' => $this->configuration['editor']['config']['toolbar']['shouldNotGroupWhenFull'] ?? true, + ]; + + // Migrate CKEditor 4 toolbarGroups + // There can only be one configuration at a time, if 'toolbarGroups' is set + // we prefer this definition above the toolbar definition. + // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-toolbarGroups + if (is_array($this->configuration['editor']['config']['toolbarGroups'] ?? null)) { + $toolbar['items'] = $this->configuration['editor']['config']['toolbarGroups']; + unset($this->configuration['editor']['config']['toolbar'], $this->configuration['editor']['config']['toolbarGroups']); + } + + // Migrate CKEditor 4 toolbar templates + // Resolve toolbar template and override current toolbar + // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-toolbar + if (is_string($this->configuration['editor']['config']['toolbar'] ?? null)) { + $toolbarName = 'toolbar_' . trim($this->configuration['editor']['config']['toolbar']); + if (is_array($this->configuration['editor']['config'][$toolbarName] ?? null)) { + $toolbar['items'] = $this->configuration['editor']['config'][$toolbarName]; + unset($this->configuration['editor']['config']['toolbar'], $this->configuration['editor']['config'][$toolbarName]); + } + } + + // Collect toolbar items + if (is_array($this->configuration['editor']['config']['toolbar'] ?? null)) { + $toolbar['items'] = $this->configuration['editor']['config']['toolbar']['items'] ?? $this->configuration['editor']['config']['toolbar']; + } + + $toolbar['items'] = $this->migrateToolbarItems($toolbar['items']); + $this->configuration['editor']['config']['toolbar'] = $toolbar; + } + + protected function migrateToolbarItems(array $items): array + { + $toolbarItems = []; + foreach ($items as $item) { + if (is_string($item)) { + $toolbarItems[] = $this->migrateToolbarButton($item); + continue; + } + if (is_array($item)) { + // Expand CKEditor 4 preset toolbar groups + if (is_string($item['name'] ?? null) && count($item) === 1 && isset(self::TOOLBAR_MAIN_GROUPS_MAP[$item['name']])) { + $item['groups'] = self::TOOLBAR_MAIN_GROUPS_MAP[$item['name']]; + } + // Flatten CKEditor 4 arrays that only have strings assigned + if (count($item) === count(array_filter($item, static fn(mixed $value): bool => is_string($value)))) { + $migratedToolbarItems = $item; + $migratedToolbarItems = $this->migrateToolbarButtons($migratedToolbarItems); + $migratedToolbarItems = $this->migrateToolbarSpacers($migratedToolbarItems); + array_push($toolbarItems, ...$migratedToolbarItems); + $toolbarItems[] = '|'; + continue; + } + // Flatten CKEditor 4 named groups + if (is_string($item['name'] ?? null) && is_array($item['items'] ?? null)) { + $migratedToolbarItems = $item['items']; + $migratedToolbarItems = $this->migrateToolbarButtons($migratedToolbarItems); + $migratedToolbarItems = $this->migrateToolbarSpacers($migratedToolbarItems); + array_push($toolbarItems, ...$migratedToolbarItems); + $toolbarItems[] = '|'; + continue; + } + // Expand CKEditor 4 toolbar groups + if (is_string($item['name'] ?? null) && is_array($item['groups'] ?? null)) { + $itemGroups = array_filter($item['groups'], static fn(mixed $itemGroup): bool => is_string($itemGroup)); + + // Process Main CKEditor 4 Groups + $unGroupedToolbarItems = []; + foreach ($itemGroups as $itemGroup) { + if (isset(self::TOOLBAR_MAIN_GROUPS_MAP[$itemGroup])) { + array_push($unGroupedToolbarItems, ...self::TOOLBAR_MAIN_GROUPS_MAP[$itemGroup]); + $unGroupedToolbarItems[] = '|'; + continue; + } + $unGroupedToolbarItems[] = $itemGroup; + } + + // Process CKEditor 4 Groups + $groupedToolbarItems = []; + foreach ($itemGroups as $itemGroup) { + if (isset(self::TOOLBAR_GROUPS_MAP[$itemGroup])) { + array_push($groupedToolbarItems, ...self::TOOLBAR_GROUPS_MAP[$itemGroup]); + $groupedToolbarItems[] = '|'; + continue; + } + $groupedToolbarItems[] = $itemGroup; + } + + $migratedToolbarItems = $groupedToolbarItems; + $migratedToolbarItems = $this->migrateToolbarButtons($migratedToolbarItems); + $migratedToolbarItems = $this->migrateToolbarSpacers($migratedToolbarItems); + array_push($toolbarItems, ...$migratedToolbarItems); + $toolbarItems[] = '|'; + continue; + } + + $toolbarItems[] = $item; + } + } + + $toolbarItems = $this->migrateToolbarLinebreaks($toolbarItems); + $toolbarItems = $this->migrateToolbarCleanup($toolbarItems); + + return array_values($toolbarItems); + } + + protected function migrateToolbarButton(string $buttonName): ?string + { + if (array_key_exists($buttonName, self::BUTTON_MAP)) { + return self::BUTTON_MAP[$buttonName]; + } + return $buttonName; + } + + protected function migrateToolbarButtons(array $toolbarItems): array + { + $processedItems = []; + foreach ($toolbarItems as $toolbarItem) { + if (is_string($toolbarItem)) { + if (($toolbarItem = $this->migrateToolbarButton($toolbarItem)) !== null) { + $processedItems[] = $this->migrateToolbarButton($toolbarItem); + } + } else { + $processedItems[] = $toolbarItem; + } + } + + return $processedItems; + } + + protected function migrateToolbarSpacers(array $toolbarItems): array + { + $processedItems = []; + foreach ($toolbarItems as $toolbarItem) { + if (is_string($toolbarItem)) { + $toolbarItem = str_replace('-', '|', $toolbarItem); + } + $processedItems[] = $toolbarItem; + } + + return $processedItems; + } + + protected function migrateToolbarLinebreaks(array $toolbarItems): array + { + $processedItems = []; + foreach ($toolbarItems as $toolbarItem) { + if (is_string($toolbarItem)) { + $toolbarItem = str_replace('/', '-', $toolbarItem); + } + $processedItems[] = $toolbarItem; + } + + return $processedItems; + } + + protected function migrateToolbarCleanup(array $toolbarItems): array + { + // Ensure buttons are only added once to the toolbar. + $searchValues = []; + foreach ($toolbarItems as $toolbarKey => $toolbarItem) { + if (is_string($toolbarItem) && !in_array($toolbarItem, ['|', '-'])) { + if (array_key_exists($toolbarItem, $searchValues)) { + unset($toolbarItems[$toolbarKey]); + } else { + $searchValues[$toolbarItem] = true; + } + } + } + + $previousItem = null; + $previousKey = null; + foreach ($toolbarItems as $toolbarKey => $toolbarItem) { + if ($previousItem === null && ($toolbarItem === '|' || $toolbarItem === '-')) { + unset($toolbarItems[$toolbarKey]); + continue; + } + + if ($previousItem === '|' && ($toolbarItem === '|' || $toolbarItem === '-')) { + unset($toolbarItems[$previousKey]); + } + + $previousKey = $toolbarKey; + $previousItem = $toolbarItem; + } + + $lastToolbarItem = array_slice($toolbarItems, -1, 1); + if ($lastToolbarItem === ['-'] || $lastToolbarItem === ['|']) { + array_pop($toolbarItems); + } + + return array_values($toolbarItems); + } + + protected function migrateRemoveButtonsFromToolbar(): void + { + if (!isset($this->configuration['editor']['config']['removeButtons'])) { + return; + } + + if (is_string($this->configuration['editor']['config']['removeButtons'])) { + $this->configuration['editor']['config']['removeButtons'] = GeneralUtility::trimExplode( + ',', + $this->configuration['editor']['config']['removeButtons'], + true + ); + } + + $removeItems = []; + foreach ($this->configuration['editor']['config']['removeButtons'] as $buttonName) { + if (array_key_exists($buttonName, self::BUTTON_MAP)) { + if (self::BUTTON_MAP[$buttonName] !== null) { + $removeItems[] = self::BUTTON_MAP[$buttonName]; + } + } else { + $removeItems[] = $buttonName; + } + } + + foreach ($removeItems as $name) { + $this->removeToolbarItem($name); + } + + // Cleanup final configuration after migration + unset($this->configuration['editor']['config']['removeButtons']); + } + + protected function migrateFormatTagsToHeadings(): void + { + // new definition is in place, no migration is done + if (isset($this->configuration['editor']['config']['heading']['options'])) { + // discard legacy configuration if new configuration exists + unset($this->configuration['editor']['config']['format_tags']); + return; + } + // migrate format_tags to custom buttons + if (isset($this->configuration['editor']['config']['format_tags'])) { + $formatTags = explode(';', $this->configuration['editor']['config']['format_tags']); + $allowedHeadings = []; + foreach ($formatTags as $paragraphTag) { + switch (strtolower($paragraphTag)) { + case 'p': + $allowedHeadings[] = [ + 'model' => 'paragraph', + 'title' => 'Paragraph', + ]; + break; + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + $headingNumber = substr($paragraphTag, -1); + $allowedHeadings[] = [ + 'model' => 'heading' . $headingNumber, + 'view' => 'h' . $headingNumber, + 'title' => 'Heading ' . $headingNumber, + ]; + break; + case 'pre': + $allowedHeadings[] = [ + 'model' => 'formatted', + 'view' => 'pre', + 'title' => 'Formatted', + ]; + } + } + + // remove legacy configuration after migration + unset($this->configuration['editor']['config']['format_tags']); + $this->configuration['editor']['config']['heading']['options'] = $allowedHeadings; + } + } + + protected function migrateStylesSetToStyleDefinitions(): void + { + // new definition is in place, no migration is done + if (isset($this->configuration['editor']['config']['style']['definitions'])) { + // discard legacy configuration if new configuration exists + unset($this->configuration['editor']['config']['stylesSet']); + return; + } + // Migrate 'stylesSet' to 'styles' => 'definitions' + if (isset($this->configuration['editor']['config']['stylesSet'])) { + $styleDefinitions = []; + foreach ($this->configuration['editor']['config']['stylesSet'] as $styleSet) { + if (!isset($styleSet['name'], $styleSet['element'])) { + // @todo: log + continue; + } + $class = $styleSet['attributes']['class'] ?? null; + $definition = [ + 'name' => $styleSet['name'], + 'element' => $styleSet['element'], + 'classes' => [''], + ]; + if ($class) { + $definition['classes'] = explode(' ', $class); + } + $styleDefinitions[] = $definition; + } + + // remove legacy configuration after migration + unset($this->configuration['editor']['config']['stylesSet']); + $this->configuration['editor']['config']['style']['definitions'] = $styleDefinitions; + } + } + + protected function migrateContentsCssToArray(): void + { + if (isset($this->configuration['editor']['config']['contentsCss'])) { + if (!is_array($this->configuration['editor']['config']['contentsCss'])) { + if (empty($this->configuration['editor']['config']['contentsCss'])) { + unset($this->configuration['editor']['config']['contentsCss']); + return; + } + $this->configuration['editor']['config']['contentsCss'] = (array)$this->configuration['editor']['config']['contentsCss']; + } + + $this->configuration['editor']['config']['contentsCss'] = array_map(static function (mixed $styleSrc): mixed { + // Trim values, if input is a string, otherwise leave as-is (will be filtered out) + return is_string($styleSrc) ? trim($styleSrc) : $styleSrc; + }, $this->configuration['editor']['config']['contentsCss']); + $this->configuration['editor']['config']['contentsCss'] = array_values( + array_filter($this->configuration['editor']['config']['contentsCss'], static function (mixed $styleSrc): bool { + // We care for non-empty strings only + return is_string($styleSrc) && $styleSrc !== ''; + }) + ); + } + } + + protected function migrateTypo3LinkAdditionalAttributes(): void + { + if (!isset($this->configuration['editor']['config']['typo3link']['additionalAttributes'])) { + return; + } + $additionalAttributes = $this->configuration['editor']['config']['typo3link']['additionalAttributes']; + unset($this->configuration['editor']['config']['typo3link']['additionalAttributes']); + if ($this->configuration['editor']['config']['typo3link'] === []) { + unset($this->configuration['editor']['config']['typo3link']); + } + if (!is_array($additionalAttributes) || $additionalAttributes === []) { + return; + } + $this->configuration['editor']['config']['htmlSupport']['allow'][] = [ + 'name' => 'a', + 'attributes' => array_values($additionalAttributes), + ]; + } + + protected function parseRuleProperties(string $properties, string $type): ?string + { + $groupsPatterns = [ + 'styles' => '/{([^}]+)}/', + 'attrs' => '/\[([^\]]+)\]/', + 'classes' => '/\(([^\)]+)\)/', + ]; + $pattern = $groupsPatterns[$type] ?? null; + if ($pattern === null) { + throw new \InvalidArgumentException('Expected type to be styles, attrs or classes', 1696326899); + } + + $matches = []; + if (preg_match($pattern, $properties, $matches) === 1) { + return trim($matches[1]); + } + + return null; + } + + /** + * Based on https://github.com/ckeditor/ckeditor4/blob/4.23.0-lts/core/filter.js#L1438 + */ + protected function parseRulesString(string $input): array + { + $ruleConfig = []; + do { + $matches = []; + $res = preg_match( + // Based on https://github.com/ckeditor/ckeditor4/blob/4.23.0-lts/core/filter.js#L1431 + // < elements >< styles, attributes and classes >< separator > + '/^([a-z0-9\-*\s]+)((?:\s*\{[!\w\-,\s\*]+\}\s*|\s*\[[!\w\-,\s\*]+\]\s*|\s*\([!\w\-,\s\*]+\)\s*){0,3})(?:;\s*|$)/i', + $input, + $matches + ); + if ($res === false || $res === 0) { + return $ruleConfig; + } + $name = $matches[1]; + $properties = $matches[2]; + $config = []; + $config['styles'] = $this->parseRuleProperties($properties, 'styles'); + $config['attributes'] = $this->parseRuleProperties($properties, 'attrs'); + $config['classes'] = $this->parseRuleProperties($properties, 'classes'); + $ruleConfig[$name] = $config; + + $input = substr($input, strlen($matches[0])); + } while ($input !== ''); + return $ruleConfig; + } + + protected function migrateAllowedContent(): void + { + $types = [ + 'allowedContent' => 'allow', + 'extraAllowedContent' => 'allow', + 'disallowedContent' => 'disallow', + ]; + + foreach ($types as $option4 => $option5) { + if (!isset($this->configuration['editor']['config'][$option4])) { + continue; + } + + if ($option4 === 'allowedContent') { + if ($this->configuration['editor']['config']['allowedContent'] === true || $this->configuration['editor']['config']['allowedContent'] === '1') { + $this->configuration['editor']['config']['htmlSupport']['allow'][] = [ + // Allow *any* tag (even custom elements) + 'name' => [ + 'pattern' => '.+', + ], + 'attributes' => true, + 'classes' => true, + 'styles' => true, + ]; + unset($this->configuration['editor']['config']['allowedContent']); + continue; + } + } + + $config4 = $this->configuration['editor']['config'][$option4]; + if (is_string($config4)) { + $config4 = $this->parseRulesString($config4); + } + + foreach ($config4 as $name => $options) { + $config = []; + if ($name === '*') { + $config['name'] = [ 'pattern' => '^[a-z]+$' ]; + } else { + $name = (string)$name; + $config['name'] = str_contains($name, '*') || str_contains($name, ' ') + ? [ 'pattern' => str_replace(['*', ' '], ['.+', '|'], $name) ] + : $name; + } + + if (is_bool($options)) { + if ($options) { + $this->configuration['editor']['config']['htmlSupport'][$option5][] = $config; + } + continue; + } + + if (!is_array($options)) { + continue; + } + + $wildcardToRegex = fn(string $v): string|array => str_contains($v, '*') ? [ 'pattern' => str_replace('*', '.+', $v) ] : $v; + if (isset($options['classes'])) { + if ($options['classes'] === '*') { + $config['classes'] = true; + } else { + $config['classes'] = array_map($wildcardToRegex, explode(',', $options['classes'])); + } + } + + if (isset($options['attributes'])) { + if ($options['attributes'] === '*') { + $config['attributes'] = true; + } else { + $config['attributes'] = array_map($wildcardToRegex, explode(',', $options['attributes'])); + } + } + + if (isset($options['styles'])) { + if ($options['styles'] === '*') { + $config['styles'] = true; + } else { + $config['styles'] = array_map($wildcardToRegex, explode(',', $options['styles'])); + } + } + $this->configuration['editor']['config']['htmlSupport'][$option5][] = $config; + } + unset($this->configuration['editor']['config'][$option4]); + } + } + + protected function handleAlignmentPlugin(): void + { + // Migrate legacy configuration + // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-justifyClasses + if (isset($this->configuration['editor']['config']['justifyClasses'])) { + if (!isset($this->configuration['editor']['config']['alignment'])) { + $legacyConfig = $this->configuration['editor']['config']['justifyClasses']; + $indexMap = [ + 0 => 'left', + 1 => 'center', + 2 => 'right', + 3 => 'justify', + ]; + foreach ($legacyConfig as $index => $class) { + $itemConfig = []; + if (isset($indexMap[$index])) { + $itemConfig['name'] = $indexMap[$index]; + } + $itemConfig['className'] = $class; + $this->configuration['editor']['config']['alignment']['options'][] = $itemConfig; + } + } + unset($this->configuration['editor']['config']['justifyClasses']); + } + $this->removeExtraPlugin('justify'); + + // Remove related configuration if plugin should not be loaded + if (in_array( + '@ckeditor/ckeditor5-alignment', + array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'), + true + )) { + // Remove toolbar items + $this->removeToolbarItem('alignment'); + $this->removeToolbarItem('alignment:left'); + $this->removeToolbarItem('alignment:right'); + $this->removeToolbarItem('alignment:center'); + $this->removeToolbarItem('alignment:justify'); + + // Remove config + if (isset($this->configuration['editor']['config']['alignment'])) { + unset($this->configuration['editor']['config']['alignment']); + } + + return; + } + + if (is_array($this->configuration['editor']['config']['alignment']['options'] ?? null)) { + $classMap = []; + foreach ($this->configuration['editor']['config']['alignment']['options'] as $option) { + if (is_string($option['name'] ?? null) + && is_string($option['className'] ?? null) + && in_array($option['name'], ['left', 'center', 'right', 'justify'])) { + $classMap[$option['name']] = $option['className']; + } + } + } + + // Default config + $this->configuration['editor']['config']['alignment'] = [ + 'options' => [ + ['name' => 'left', 'className' => $classMap['left'] ?? 'text-start'], + ['name' => 'center', 'className' => $classMap['center'] ?? 'text-center'], + ['name' => 'right', 'className' => $classMap['right'] ?? 'text-end'], + ['name' => 'justify', 'className' => $classMap['justify'] ?? 'text-justify'], + ], + ]; + } + + protected function handleWhitespacePlugin(): void + { + // Remove related configuration if plugin should not be loaded + if (in_array( + '@typo3/rte-ckeditor/plugin/whitespace.js', + array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'), + true + )) { + // Remove toolbar items + $this->removeToolbarItem('softhyphen'); + } + } + + protected function handleWordCountPlugin(): void + { + // Migrate legacy configuration + // + // CKEditor 4 used `wordcount` (lowercase), which is `wordCount` in CKEditor 5. + // The amount of properties has been reduced. + // + // see https://ckeditor.com/docs/ckeditor5/latest/features/word-count.html + if (isset($this->configuration['editor']['config']['wordcount'])) { + if (!isset($this->configuration['editor']['config']['wordCount'])) { + $legacyConfig = $this->configuration['editor']['config']['wordcount']; + if (isset($legacyConfig['showCharCount'])) { + $this->configuration['editor']['config']['wordCount']['displayCharacters'] = !empty($legacyConfig['showCharCount']); + } + if (isset($legacyConfig['showWordCount'])) { + $this->configuration['editor']['config']['wordCount']['displayWords'] = !empty($legacyConfig['showWordCount']); + } + } + unset($this->configuration['editor']['config']['wordcount']); + } + + // Remove related configuration if plugin should not be loaded + if (in_array( + '@ckeditor/ckeditor5-word-count', + array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'), + true + )) { + // Remove config + if (isset($this->configuration['editor']['config']['wordCount'])) { + unset($this->configuration['editor']['config']['wordCount']); + } + + return; + } + + // Default config + $this->configuration['editor']['config']['wordCount'] = [ + 'displayCharacters' => $this->configuration['editor']['config']['wordCount']['displayCharacters'] ?? true, + 'displayWords' => $this->configuration['editor']['config']['wordCount']['displayWords'] ?? true, + ]; + } + + protected function handleStyleDefinitions(): void + { + if (isset($this->configuration['editor']['config']['style']['definitions']) + && is_array($this->configuration['editor']['config']['style']['definitions']) + ) { + foreach ($this->configuration['editor']['config']['style']['definitions'] as $definitionIndex => $definition) { + $classes = $definition['classes'] ?? []; + if ($classes === []) { + // See CKEditor5Migrator::migrateStylesSetToStyleDefinitions - an empty array is not allowed. + // The "classes" attribute must always either be a string (even using `true` will lead to class="true"), + // or "['']" (array with empty string, leading to class=""). CKEditor 5 requires this attribute to + // be set, see https://ckeditor.com/docs/ckeditor5/latest/api/module_style_styleconfig-StyleDefinition.html + $this->configuration['editor']['config']['style']['definitions'][$definitionIndex]['classes'] = ['']; + } + } + } + } + + protected function addLinkClassesToStyleSets(): void + { + if (!isset($this->configuration['buttons']['link']['properties']['class']['allowedClasses'])) { + return; + } + + // Ensure editor.config.style.definitions exists + $this->configuration['editor']['config']['style']['definitions'] ??= []; + + $allowedClassSets = is_array($this->configuration['buttons']['link']['properties']['class']['allowedClasses']) + ? $this->configuration['buttons']['link']['properties']['class']['allowedClasses'] + : GeneralUtility::trimExplode(',', $this->configuration['buttons']['link']['properties']['class']['allowedClasses'], true); + + // Determine index where link classes should be added at to keep styles grouped + $indexToInsertElementsAt = array_key_last($this->configuration['editor']['config']['style']['definitions']) + 1; + foreach ($this->configuration['editor']['config']['style']['definitions'] as $index => $styleSetDefinition) { + if ($styleSetDefinition['element'] === 'a') { + $indexToInsertElementsAt = $index + 1; + } + } + + foreach ($allowedClassSets as $classSet) { + $allowedClasses = GeneralUtility::trimExplode(' ', $classSet); + foreach ($this->configuration['editor']['config']['style']['definitions'] as $styleSetDefinition) { + if ($styleSetDefinition['element'] === 'a' && $styleSetDefinition['classes'] === $allowedClasses) { + // allowedClasses is already configured, continue with next one + continue 2; + } + } + + // We're still here, this means $allowedClasses wasn't found + array_splice($this->configuration['editor']['config']['style']['definitions'], $indexToInsertElementsAt, 0, [[ + 'classes' => $allowedClasses, + 'element' => 'a', + 'name' => implode(' ', $allowedClasses), // we lack a human-readable name here... + ]]); + $indexToInsertElementsAt++; + } + } + + private function removeToolbarItem(string $name): void + { + $this->configuration['editor']['config']['toolbar']['removeItems'][] = $name; + $this->configuration['editor']['config']['toolbar']['removeItems'] = $this->getUniqueArrayValues($this->configuration['editor']['config']['toolbar']['removeItems']); + } + + private function removeExtraPlugin(string $name): void + { + if (!isset($this->configuration['editor']['config']['extraPlugins'])) { + return; + } + + $this->configuration['editor']['config']['extraPlugins'] = array_filter($this->configuration['editor']['config']['extraPlugins'], static function (string $value) use ($name) { + return $value !== $name; + }); + + if (empty($this->configuration['editor']['config']['extraPlugins'])) { + unset($this->configuration['editor']['config']['extraPlugins']); + return; + } + + $this->configuration['editor']['config']['extraPlugins'] = $this->getUniqueArrayValues($this->configuration['editor']['config']['extraPlugins']); + } + + /** + * Ensure to have clean array with incrementing identifiers + * to avoid JavaScript casting this to an object + */ + private function getUniqueArrayValues(array $array) + { + return array_values(array_unique($array)); + } +} diff --git a/Classes/Controller/BrowseLinksController.php b/Classes/Controller/BrowseLinksController.php new file mode 100644 index 0000000..f5f2823 --- /dev/null +++ b/Classes/Controller/BrowseLinksController.php @@ -0,0 +1,418 @@ +buttonConfig; + } + + /** + * @return array{act: string, P: array, editorId: string, contentsLanguage: string} Array of parameters which have to be added to URLs + */ + public function getUrlParameters(?array $overrides = null): array + { + return [ + 'act' => $overrides['act'] ?? $this->displayedLinkHandlerId, + 'P' => $overrides['P'] ?? $this->parameters, + 'editorId' => $this->editorId, + 'contentsLanguage' => $this->contentsLanguage, + ]; + } + + protected function initDocumentTemplate(): void + { + $this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction( + JavaScriptModuleInstruction::create('@typo3/rte-ckeditor/rte-link-browser.js') + ->invoke('initialize', $this->editorId) + ); + } + + protected function getCurrentPageId(): int + { + return (int)$this->parameters['pid']; + } + + protected function initVariables(ServerRequestInterface $request): void + { + parent::initVariables($request); + $queryParameters = $request->getQueryParams(); + $this->siteUrl = $request->getAttribute('normalizedParams')->getSiteUrl(); + $this->currentLinkParts = $queryParameters['P']['curUrl'] ?? []; + $this->editorId = $queryParameters['editorId'] ?? ''; + $this->contentsLanguage = $queryParameters['contentsLanguage'] ?? ''; + $this->contentLanguageService = $this->languageServiceFactory->create($this->contentsLanguage); + $tcaFieldConf = [ + 'enableRichtext' => true, + 'richtextConfiguration' => $this->parameters['richtextConfigurationName'] ?: null, + ]; + $this->thisConfig = $this->richtext->getConfiguration( + $this->parameters['table'], + $this->parameters['fieldName'], + (int)$this->parameters['pid'], + $this->parameters['recordType'], + $tcaFieldConf + ); + $this->buttonConfig = $this->thisConfig['buttons']['link'] ?? []; + } + + protected function initCurrentUrl(): void + { + if (empty($this->currentLinkParts)) { + return; + } + if (!empty($this->currentLinkParts['url'])) { + try { + $data = $this->linkService->resolve($this->currentLinkParts['url']); + $this->currentLinkParts['type'] = $data['type']; + unset($data['type']); + $this->currentLinkParts['url'] = $data; + if (!empty($this->currentLinkParts['url']['parameters'])) { + $this->currentLinkParts['params'] = '&' . $this->currentLinkParts['url']['parameters']; + } + } catch (UnknownLinkHandlerException $e) { + $this->flashMessageService->getMessageQueueByIdentifier()->enqueue( + new FlashMessage(message: $e->getMessage(), severity: ContextualFeedbackSeverity::ERROR) + ); + } + } + parent::initCurrentUrl(); + } + + protected function renderLinkAttributeFields(ViewInterface $view): string + { + // Processing the classes configuration + if (!empty($this->buttonConfig['properties']['class']['allowedClasses'])) { + $classesAnchorArray = is_array($this->buttonConfig['properties']['class']['allowedClasses']) + ? $this->buttonConfig['properties']['class']['allowedClasses'] + : GeneralUtility::trimExplode(',', $this->buttonConfig['properties']['class']['allowedClasses'], true); + // Collecting allowed classes and configured default values + $classesAnchor = [ + 'all' => [], + ]; + + if (is_array($this->thisConfig['classesAnchor'] ?? null)) { + foreach ($this->thisConfig['classesAnchor'] as $conf) { + if (in_array($conf['class'] ?? null, $classesAnchorArray, true)) { + $classesAnchor['all'][] = $conf['class']; + if ($conf['type'] === $this->displayedLinkHandlerId) { + $classesAnchor[$conf['type']][] = $conf['class']; + if (($this->buttonConfig[$conf['type']]['properties']['class']['default'] ?? null) === $conf['class']) { + $this->classesAnchorDefault[$conf['type']] = $conf['class']; + if (isset($conf['target'])) { + $this->classesAnchorDefaultTarget[$conf['type']] = trim((string)$conf['target']); + } + } + } + } + } + } + + $linkClass = $this->linkAttributeValues['class'] ?? ''; + if ($linkClass !== '') { + $currentLinkClassIsAllowed = true; + if (!in_array($linkClass, $classesAnchorArray, true)) { + // Current class is not a globally allowed class + $currentLinkClassIsAllowed = false; + } + if ( + isset($classesAnchor[$this->displayedLinkHandlerId]) + && in_array($linkClass, $classesAnchor['all'], true) + && !in_array($linkClass, $classesAnchor[$this->displayedLinkHandlerId], true) + ) { + // Current class is limited to specific link types but not available in current link type + $currentLinkClassIsAllowed = false; + } + + if (!$currentLinkClassIsAllowed) { + $this->classesAnchorJSOptions[$this->displayedLinkHandlerId] ??= ''; + // Add a dummy option that preserved the current class value (despite being invalid) + // in order to prevent unintentional modification of assigned classes. + $this->classesAnchorJSOptions[$this->displayedLinkHandlerId] .= sprintf( + '', + htmlspecialchars($linkClass), + htmlspecialchars( + @sprintf( + '[ ' . $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noMatchingValue') . ' ]', + $linkClass + ) + ) + ); + } + } + + // Constructing the class selector options + foreach ($classesAnchorArray as $class) { + if ( + !in_array($class, $classesAnchor['all'], true) + || ( + in_array($class, $classesAnchor['all'], true) + && is_array($classesAnchor[$this->displayedLinkHandlerId] ?? null) + && in_array($class, $classesAnchor[$this->displayedLinkHandlerId]) + ) + ) { + $selected = ''; + if ( + (($this->linkAttributeValues['class'] ?? false) === $class) + || ($this->classesAnchorDefault[$this->displayedLinkHandlerId] ?? false) === $class + ) { + $selected = 'selected="selected"'; + } + $classLabel = !empty($this->thisConfig['classes'][$class]['name']) + ? $this->getPageConfigLabel($this->thisConfig['classes'][$class]['name'], false) + : $class; + $classStyle = !empty($this->thisConfig['classes'][$class]['value']) + ? $this->thisConfig['classes'][$class]['value'] + : ''; + + $this->classesAnchorJSOptions[$this->displayedLinkHandlerId] ??= ''; + $this->classesAnchorJSOptions[$this->displayedLinkHandlerId] .= ''; + } + } + if ( + ($this->classesAnchorJSOptions[$this->displayedLinkHandlerId] ?? false) + && !( + ($this->buttonConfig['properties']['class']['required'] ?? false) + || ($this->buttonConfig[$this->displayedLinkHandlerId]['properties']['class']['required'] ?? false) + ) + ) { + $selected = ''; + if (!($this->linkAttributeValues['class'] ?? false) && !($this->classesAnchorDefault[$this->displayedLinkHandlerId] ?? false)) { + $selected = 'selected="selected"'; + } + $this->classesAnchorJSOptions[$this->displayedLinkHandlerId] = '' . $this->classesAnchorJSOptions[$this->displayedLinkHandlerId]; + } + } + // Default target + $this->defaultLinkTarget = ($this->classesAnchorDefault[$this->displayedLinkHandlerId] ?? false) && ($this->classesAnchorDefaultTarget[$this->displayedLinkHandlerId] ?? false) + ? $this->classesAnchorDefaultTarget[$this->displayedLinkHandlerId] + : ($this->buttonConfig[$this->displayedLinkHandlerId]['properties']['target']['default'] ?? $this->buttonConfig['properties']['target']['default'] ?? ''); + + return parent::renderLinkAttributeFields($view); + } + + /** + * Localize a label obtained from Page TSConfig + * + * @param string $string The label to be localized + * @param bool $JScharCode If it needs to be converted to an array of char numbers + * @return string Localized string + */ + protected function getPageConfigLabel(string $string, bool $JScharCode = true): string + { + $label = $this->getLanguageService()->sL(trim($string)); + $label = str_replace(['\\\'', '"'], ['\'', '\\"'], $label); + return $JScharCode ? GeneralUtility::quoteJSvalue($label) : $label; + } + + protected function renderCurrentUrl(ViewInterface $view): void + { + $view->assign('removeCurrentLink', true); + parent::renderCurrentUrl($view); + } + + /** + * @return string[] + */ + protected function getAllowedItems(): array + { + $allowedItems = parent::getAllowedItems(); + + if (isset($this->thisConfig['allowedTypes'])) { + $allowedItems = array_intersect($allowedItems, GeneralUtility::trimExplode(',', $this->thisConfig['allowedTypes'], true)); + } elseif (isset($this->thisConfig['blindLinkOptions'])) { + // @todo Deprecate this option + $allowedItems = array_diff($allowedItems, GeneralUtility::trimExplode(',', $this->thisConfig['blindLinkOptions'], true)); + } + + if (is_array($this->buttonConfig['options'] ?? null) && !empty($this->buttonConfig['options']['removeItems'])) { + $allowedItems = array_diff($allowedItems, GeneralUtility::trimExplode(',', $this->buttonConfig['options']['removeItems'], true)); + } + + return $allowedItems; + } + + /** + * @return string[] + */ + protected function getAllowedLinkAttributes(): array + { + $allowedLinkAttributes = parent::getAllowedLinkAttributes(); + + if (isset($this->thisConfig['allowedOptions'])) { + $allowedLinkAttributes = array_intersect($allowedLinkAttributes, GeneralUtility::trimExplode(',', $this->thisConfig['allowedOptions'], true)); + } elseif (isset($this->thisConfig['blindLinkFields'])) { + // @todo Deprecate this option + $allowedLinkAttributes = array_diff($allowedLinkAttributes, GeneralUtility::trimExplode(',', $this->thisConfig['blindLinkFields'], true)); + } + + return $allowedLinkAttributes; + } + + /** + * Create an array of link attribute field rendering definitions + * + * @return string[] + */ + protected function getLinkAttributeFieldDefinitions(): array + { + $fieldRenderingDefinitions = parent::getLinkAttributeFieldDefinitions(); + $fieldRenderingDefinitions['class'] = $this->getClassField(); + $fieldRenderingDefinitions['target'] = $this->getTargetField(); + $fieldRenderingDefinitions['rel'] = $this->getRelField(); + if (empty($this->buttonConfig['queryParametersSelector']['enabled'])) { + unset($fieldRenderingDefinitions['params']); + } + return $fieldRenderingDefinitions; + } + + protected function getRelField(): string + { + if (empty($this->buttonConfig['relAttribute']['enabled'])) { + return ''; + } + + $currentRel = ''; + if ($this->displayedLinkHandler === $this->currentLinkHandler + && !empty($this->currentLinkParts) + && is_string($this->linkAttributeValues['rel'] ?? null) + ) { + $currentRel = $this->linkAttributeValues['rel']; + } + + return ' +
+ + +
+ '; + } + + protected function getTargetField(): string + { + $targetSelectorConfig = []; + if (is_array($this->buttonConfig['targetSelector'] ?? null)) { + $targetSelectorConfig = $this->buttonConfig['targetSelector']; + } + $target = !empty($this->linkAttributeValues['target']) ? $this->linkAttributeValues['target'] : $this->defaultLinkTarget; + $lang = $this->getLanguageService(); + + $disabled = $targetSelectorConfig['disabled'] ?? false; + if ($disabled) { + return ''; + } + + return ' +
+ + + + ' . $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:top') . ' + ' . $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:newWindow') . ' + +
'; + } + + /** + * Return html code for the class selector + * + * @return string the html code to be added to the form + */ + protected function getClassField(): string + { + if (!isset($this->classesAnchorJSOptions[$this->displayedLinkHandlerId])) { + return ''; + } + + return ' +
+ + +
+ '; + } + + /** + * @return string[] Array of body-tag attributes + */ + protected function getBodyTagAttributes(): array + { + $parameters = parent::getBodyTagAttributes(); + $parameters['data-site-url'] = $this->siteUrl; + $parameters['data-default-link-target'] = $this->defaultLinkTarget; + return $parameters; + } +} diff --git a/Classes/EventListener/AfterRichtextConfigurationPreparedEventListener.php b/Classes/EventListener/AfterRichtextConfigurationPreparedEventListener.php new file mode 100644 index 0000000..bc4e56f --- /dev/null +++ b/Classes/EventListener/AfterRichtextConfigurationPreparedEventListener.php @@ -0,0 +1,31 @@ +setConfiguration((new CKEditor5Migrator($event->getConfiguration()))->get()); + } +} diff --git a/Classes/Form/Element/Event/AfterGetExternalPluginsEvent.php b/Classes/Form/Element/Event/AfterGetExternalPluginsEvent.php new file mode 100644 index 0000000..80918a5 --- /dev/null +++ b/Classes/Form/Element/Event/AfterGetExternalPluginsEvent.php @@ -0,0 +1,41 @@ +data; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } +} diff --git a/Classes/Form/Element/Event/AfterPrepareConfigurationForEditorEvent.php b/Classes/Form/Element/Event/AfterPrepareConfigurationForEditorEvent.php new file mode 100644 index 0000000..59ac60a --- /dev/null +++ b/Classes/Form/Element/Event/AfterPrepareConfigurationForEditorEvent.php @@ -0,0 +1,41 @@ +data; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } +} diff --git a/Classes/Form/Element/Event/BeforeGetExternalPluginsEvent.php b/Classes/Form/Element/Event/BeforeGetExternalPluginsEvent.php new file mode 100644 index 0000000..01545e9 --- /dev/null +++ b/Classes/Form/Element/Event/BeforeGetExternalPluginsEvent.php @@ -0,0 +1,41 @@ +data; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } +} diff --git a/Classes/Form/Element/Event/BeforePrepareConfigurationForEditorEvent.php b/Classes/Form/Element/Event/BeforePrepareConfigurationForEditorEvent.php new file mode 100644 index 0000000..6e3570a --- /dev/null +++ b/Classes/Form/Element/Event/BeforePrepareConfigurationForEditorEvent.php @@ -0,0 +1,41 @@ +data; + } + + public function getConfiguration(): array + { + return $this->configuration; + } + + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } +} diff --git a/Classes/Form/Element/RichTextElement.php b/Classes/Form/Element/RichTextElement.php new file mode 100644 index 0000000..456ce3e --- /dev/null +++ b/Classes/Form/Element/RichTextElement.php @@ -0,0 +1,462 @@ + [ + 'renderType' => 'localizationStateSelector', + ], + 'otherLanguageContent' => [ + 'renderType' => 'otherLanguageContent', + 'after' => [ + 'localizationStateSelector', + ], + ], + 'defaultLanguageDifferences' => [ + 'renderType' => 'defaultLanguageDifferences', + 'after' => [ + 'otherLanguageContent', + ], + ], + ]; + + /** + * This property contains configuration related to the RTE + * But only the .editor configuration part + * + * @var array + */ + protected $rteConfiguration = []; + + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + private readonly UriBuilder $uriBuilder, + private readonly Locales $locales, + private readonly SystemResourcePublisherInterface $resourcePublisher, + private readonly SystemResourceFactory $systemResourceFactory, + ) {} + + /** + * Renders the ckeditor element + * + * @throws \InvalidArgumentException + */ + public function render(): array + { + $languageService = $this->getLanguageService(); + + $resultArray = $this->initializeResultArray(); + $parameterArray = $this->data['parameterArray']; + $config = $parameterArray['fieldConf']['config']; + + $fieldId = $this->sanitizeFieldId($parameterArray['itemFormElName']); + $itemFormElementName = $this->data['parameterArray']['itemFormElName']; + + $value = $this->data['parameterArray']['itemFormElValue'] ?? null; + + $fieldInformationResult = $this->renderFieldInformation(); + $fieldInformationHtml = $fieldInformationResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); + + $fieldControlResult = $this->renderFieldControl(); + $fieldControlHtml = $fieldControlResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false); + + $fieldWizardResult = $this->renderFieldWizard(); + $fieldWizardHtml = $fieldWizardResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false); + + $this->rteConfiguration = $config['richtextConfiguration']['editor'] ?? []; + $ckeditorConfiguration = $this->resolveCkEditorConfiguration(); + + $ckeditorAttributes = GeneralUtility::implodeAttributes([ + 'id' => $fieldId . 'ckeditor5', + 'options' => GeneralUtility::jsonEncodeForHtmlAttribute($ckeditorConfiguration, false), + ], true); + + $textareaAttributes = GeneralUtility::implodeAttributes([ + 'slot' => 'textarea', + 'id' => $fieldId, + 'name' => $itemFormElementName, + 'rows' => '18', + 'class' => 'form-control', + 'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config), + ], true); + + $html = []; + $html[] = $fieldInformationHtml; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = ''; + $html[] = ''; + $html[] = ''; + $html[] = '
'; + if (!empty($fieldControlHtml)) { + $html[] = '
'; + $html[] = '
'; + $html[] = $fieldControlHtml; + $html[] = '
'; + $html[] = '
'; + } + if (!empty($fieldWizardHtml)) { + $html[] = '
'; + $html[] = $fieldWizardHtml; + $html[] = '
'; + } + $html[] = '
'; + $html[] = '
'; + + $nullControlNameEscaped = htmlspecialchars('control[active][' . $this->data['tableName'] . '][' . $this->data['databaseRow']['uid'] . '][' . $this->data['fieldName'] . ']'); + + $fullElement = $html; + // @todo - The logic for hasNullCheckboxButNoPlaceholder() / hasNullCheckboxWithPlaceholder() wants to be streamlined here; + // Ideally, a placeholder should only be an instructive placeholder and not conflict with usage of a "default fallback". + // Instead of "[x] Set value (Default: …)" it might better be to use "[x] Set value (Fallback: …)", because what is shown as "default" here is not really the final value + // of the saved element, but what is inerhited as fallback values from a possible rendering chain. Looking at you, sys_file_reference IRRE. + if ($this->hasNullCheckboxButNoPlaceholder()) { + $checked = $value !== null ? ' checked="checked"' : ''; + $fullElement = []; + $fullElement[] = '
'; + $fullElement[] = '
'; + $fullElement[] = ''; + $fullElement[] = ''; + $fullElement[] = ''; + $fullElement[] = '
'; + $fullElement[] = implode(LF, $html); + } elseif ($this->hasNullCheckboxWithPlaceholder()) { + $checked = $value !== null ? ' checked="checked"' : ''; + // Note that we draw the raw placeholder from $config instead of $ckeditorConfiguration so it + // contains the full HTML markup. $ckeditorConfiguration['placeholder'] has strip_tags() applied. + // The full HTML is only emitted with htmlspecialchars(), and later parsed by CKEditor. + // The HTML-stripped placeholder is used for the label of the nullable checkbox. + + $placeholder = trim((string)($ckeditorConfiguration['placeholder'] ?? '')); + $defaultValue = ''; + $rawPlaceholder = trim((string)($config['placeholder'] ?? '')); + if ($rawPlaceholder !== '') { + $defaultValue = $rawPlaceholder; + } + if ($placeholder !== '') { + $shortenedPlaceholder = GeneralUtility::fixed_lgd_cs($placeholder, 20); + if ($placeholder !== $shortenedPlaceholder) { + $overrideLabel = sprintf( + $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override'), + '' . htmlspecialchars($shortenedPlaceholder) . '' + ); + } else { + $overrideLabel = sprintf( + $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override'), + htmlspecialchars($placeholder) + ); + } + } else { + $overrideLabel = $languageService->sL( + 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.placeholder.override_not_available' + ); + } + + $placeholderCkeditorAttributes = GeneralUtility::implodeAttributes([ + 'id' => $fieldId . '-placeholder-ckeditor5', + 'options' => GeneralUtility::jsonEncodeForHtmlAttribute([ + ...$ckeditorConfiguration, + 'readOnly' => true, + ], false), + ], true); + + $placeholderTextareaAttributes = GeneralUtility::implodeAttributes([ + 'slot' => 'textarea', + 'id' => $fieldId . '-placeholder', + 'rows' => '18', + 'class' => 'form-control', + ], true); + + $fullElement = []; + $fullElement[] = '
'; + $fullElement[] = ''; + $fullElement[] = ''; + $fullElement[] = ''; + $fullElement[] = '
'; + $fullElement[] = '
'; + $fullElement[] = '
'; + $fullElement[] = ''; + $fullElement[] = ''; + $fullElement[] = ''; + $fullElement[] = '
'; + $fullElement[] = '
'; + $fullElement[] = '
'; + $fullElement[] = implode(LF, $html); + $fullElement[] = '
'; + } + + $fullElement = '
' . implode(LF, $fullElement) . '
'; + + $resultArray['html'] = $this->wrapWithFieldsetAndLegend($fullElement); + $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/rte-ckeditor/ckeditor5.js'); + + $uiLanguage = $ckeditorConfiguration['language']['ui']; + if ($this->translationExists($uiLanguage)) { + $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/ckeditor5/translations/' . $uiLanguage . '.js'); + } + + $contentLanguage = $ckeditorConfiguration['language']['content']; + if ($this->translationExists($contentLanguage)) { + $resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/ckeditor5/translations/' . $contentLanguage . '.js'); + } + + $resultArray['stylesheetFiles'][] = 'EXT:rte_ckeditor/Resources/Public/Css/editor.css'; + + return $resultArray; + } + + /** + * Determine the contents language iso code + */ + protected function getLanguageIsoCodeOfContent(): string + { + $currentLanguageUid = ($this->data['databaseRow']['language_tag'] ?? 0); + if (is_array($currentLanguageUid)) { + $currentLanguageUid = $currentLanguageUid[0]; + } + $contentLanguageUid = (int)max($currentLanguageUid, 0); + if ($contentLanguageUid) { + // the language rows might not be fully initialized, so we fall back to en-US in this case + $contentLanguage = $this->data['systemLanguageRows'][$currentLanguageUid]['iso'] ?? 'en-US'; + } else { + $contentLanguage = $this->rteConfiguration['config']['defaultContentLanguage'] ?? 'en-US'; + } + $languageCodeParts = explode('_', $contentLanguage); + $contentLanguage = strtolower($languageCodeParts[0]) . (!empty($languageCodeParts[1]) ? '_' . strtoupper($languageCodeParts[1]) : ''); + // Find the configured language in the list of localization locales, if not found, default to 'en'. + if ($contentLanguage === 'default' || !$this->locales->isValidLanguageKey($contentLanguage)) { + $contentLanguage = 'en'; + } + return $contentLanguage; + } + + protected function resolveCkEditorConfiguration(): array + { + $configuration = $this->prepareConfigurationForEditor(); + + foreach ($this->getExtraPlugins() as $extraPluginName => $extraPluginConfig) { + $configName = $extraPluginConfig['configName'] ?? $extraPluginName; + if (!empty($extraPluginConfig['config']) && is_array($extraPluginConfig['config'])) { + if (empty($configuration[$configName])) { + $configuration[$configName] = $extraPluginConfig['config']; + } elseif (is_array($configuration[$configName])) { + $configuration[$configName] = array_replace_recursive($extraPluginConfig['config'], $configuration[$configName]); + } + } + } + if (isset($this->data['parameterArray']['fieldConf']['config']['placeholder'])) { + // Note that HTML tags are stripped here, because CKEditor does not parse placeholder text. + // Without it, the HTML code would be displayed as-is. + $configuration['placeholder'] = strip_tags((string)$this->data['parameterArray']['fieldConf']['config']['placeholder']); + } + return $configuration; + } + + /** + * Get configuration of external/additional plugins + */ + protected function getExtraPlugins(): array + { + $externalPlugins = $this->rteConfiguration['externalPlugins'] ?? []; + $externalPlugins = $this->eventDispatcher + ->dispatch(new BeforeGetExternalPluginsEvent($externalPlugins, $this->data)) + ->getConfiguration(); + + $urlParameters = [ + 'P' => [ + 'table' => $this->data['tableName'], + 'uid' => $this->data['databaseRow']['uid'], + 'fieldName' => $this->data['fieldName'], + 'recordType' => $this->data['recordTypeValue'], + 'pid' => $this->data['effectivePid'], + 'richtextConfigurationName' => $this->data['parameterArray']['fieldConf']['config']['richtextConfigurationName'], + ], + ]; + + $pluginConfiguration = []; + foreach ($externalPlugins as $pluginName => $configuration) { + $pluginConfiguration[$pluginName] = [ + 'configName' => $configuration['configName'] ?? $pluginName, + ]; + unset($configuration['configName']); + // CKEditor 4 style config, unused in CKEditor 5 and not forwarded to the resutling plugin config + unset($configuration['resource']); + + if ($configuration['route'] ?? null) { + $configuration['routeUrl'] = (string)$this->uriBuilder->buildUriFromRoute($configuration['route'], $urlParameters); + } + + $pluginConfiguration[$pluginName]['config'] = $configuration; + } + + $pluginConfiguration = $this->eventDispatcher + ->dispatch(new AfterGetExternalPluginsEvent($pluginConfiguration, $this->data)) + ->getConfiguration(); + return $pluginConfiguration; + } + + /** + * Add configuration to replace LLL: references with the translated value + */ + protected function replaceLanguageFileReferences(array $configuration): array + { + foreach ($configuration as $key => $value) { + if (is_array($value)) { + $configuration[$key] = $this->replaceLanguageFileReferences($value); + } elseif (is_string($value)) { + $configuration[$key] = $this->getLanguageService()->sL($value); + } + } + return $configuration; + } + + /** + * Add configuration to replace absolute EXT: paths with relative ones + */ + protected function replaceAbsolutePathsToRelativeResourcesPath(array $configuration): array + { + foreach ($configuration as $key => $value) { + if (is_array($value)) { + $configuration[$key] = $this->replaceAbsolutePathsToRelativeResourcesPath($value); + } elseif (is_string($value) + && $value !== '' + // @todo: this check should vanish, once not every config key is iterated over + && PathUtility::isExtensionPath(strtoupper($value), true) + ) { + $configuration[$key] = $this->resolveUrlPath($value); + } + } + return $configuration; + } + + /** + * Resolves system resources an absolute web URL + */ + protected function resolveUrlPath(string $value): string + { + $resource = $this->systemResourceFactory->createPublicResource($value); + return (string)$this->resourcePublisher->generateUri($resource, null); + } + + /** + * Compiles the configuration set from the outside + * to have it easily injected into the CKEditor. + * + * @return array the configuration + */ + protected function prepareConfigurationForEditor(): array + { + // Ensure custom config is empty so nothing additional is loaded + // Of course this can be overridden by the editor configuration below + $configuration = [ + 'customConfig' => '', + 'label' => $this->data['parameterArray']['fieldConf']['label'] ?? '', + ]; + + if ($this->data['parameterArray']['fieldConf']['config']['readOnly'] ?? false) { + $configuration['readOnly'] = true; + } + + if (is_array($this->rteConfiguration['config'] ?? null)) { + $configuration = array_replace_recursive($configuration, $this->rteConfiguration['config']); + } + + $configuration = $this->eventDispatcher + ->dispatch(new BeforePrepareConfigurationForEditorEvent($configuration, $this->data)) + ->getConfiguration(); + + // Set the UI language of the editor if not hard-coded by the existing configuration + if (empty($configuration['language']) + || (is_array($configuration['language']) && empty($configuration['language']['ui'])) + ) { + $userLang = (string)($this->getBackendUser()->user['lang'] ?: 'en'); + $configuration['language']['ui'] = $userLang === 'default' ? 'en' : $userLang; + } elseif (!is_array($configuration['language'])) { + $configuration['language'] = [ + 'ui' => $configuration['language'], + ]; + } + $configuration['language']['content'] = $this->getLanguageIsoCodeOfContent(); + + // Replace all label references + $configuration = $this->replaceLanguageFileReferences($configuration); + // Replace all paths + $configuration = $this->replaceAbsolutePathsToRelativeResourcesPath($configuration); + + // unless explicitly set, the debug mode is enabled in development context + if (!isset($configuration['debug'])) { + $configuration['debug'] = ($GLOBALS['TYPO3_CONF_VARS']['BE']['debug'] ?? false) && Environment::getContext()->isDevelopment(); + } + + $configuration = $this->eventDispatcher + ->dispatch(new AfterPrepareConfigurationForEditorEvent($configuration, $this->data)) + ->getConfiguration(); + + return $configuration; + } + + protected function sanitizeFieldId(string $itemFormElementName): string + { + $fieldId = (string)preg_replace('/[^a-zA-Z0-9_:-]/', '_', $itemFormElementName); + return htmlspecialchars((string)preg_replace('/^[^a-zA-Z]/', 'x', $fieldId)); + } + + protected function translationExists(string $language): bool + { + $fileName = GeneralUtility::getFileAbsFileName('EXT:rte_ckeditor/Resources/Public/Contrib/translations/' . $language . '.js'); + return file_exists($fileName); + } +} diff --git a/Classes/Form/Resolver/RichTextNodeResolver.php b/Classes/Form/Resolver/RichTextNodeResolver.php new file mode 100644 index 0000000..0cdb80e --- /dev/null +++ b/Classes/Form/Resolver/RichTextNodeResolver.php @@ -0,0 +1,56 @@ +data = $data; + } + + /** + * Returns RichTextElement as class name if RTE widget should be rendered. + * + * @return string|null New class name or null if this resolver does not change current class name. + */ + public function resolve(): ?string + { + $parameterArray = $this->data['parameterArray']; + if (// If RTE is enabled for field + (bool)($parameterArray['fieldConf']['config']['enableRichtext'] ?? false) === true + // If RTE config is found (prepared by TcaText data provider) + && is_array($parameterArray['fieldConf']['config']['richtextConfiguration'] ?? null) + // If RTE is not disabled on configuration level + && !($parameterArray['fieldConf']['config']['richtextConfiguration']['disabled'] ?? false) + ) { + return RichTextElement::class; + } + return null; + } +} diff --git a/Configuration/Backend/Routes.php b/Configuration/Backend/Routes.php new file mode 100644 index 0000000..e80a7ba --- /dev/null +++ b/Configuration/Backend/Routes.php @@ -0,0 +1,15 @@ + [ + 'path' => '/rte/wizard/browselinks', + 'target' => BrowseLinksController::class . '::mainAction', + ], +]; diff --git a/Configuration/JavaScriptModules.php b/Configuration/JavaScriptModules.php new file mode 100644 index 0000000..264b8ff --- /dev/null +++ b/Configuration/JavaScriptModules.php @@ -0,0 +1,62 @@ + [ + 'backend', + ], + 'imports' => [ + '@typo3/rte-ckeditor/' => [ + 'path' => 'EXT:rte_ckeditor/Resources/Public/JavaScript/', + 'exclude' => [ + 'EXT:core/Resources/Public/JavaScript/legacy/', + ], + ], + '@typo3/ckeditor5/translations/' => 'EXT:rte_ckeditor/Resources/Public/Contrib/translations/', + '@ckeditor/ckeditor5-alignment' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-alignment.js', + '@ckeditor/ckeditor5-autoformat' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-autoformat.js', + '@ckeditor/ckeditor5-basic-styles' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-basic-styles.js', + '@ckeditor/ckeditor5-block-quote' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-block-quote.js', + '@ckeditor/ckeditor5-clipboard' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-clipboard.js', + '@ckeditor/ckeditor5-code-block' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-code-block.js', + '@ckeditor/ckeditor5-core' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-core.js', + '@ckeditor/ckeditor5-editor-classic' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-editor-classic.js', + '@ckeditor/ckeditor5-editor-decoupled' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-editor-decoupled.js', + '@ckeditor/ckeditor5-editor-multi-root' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-editor-multi-root.js', + '@ckeditor/ckeditor5-engine' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-engine.js', + '@ckeditor/ckeditor5-enter' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-enter.js', + '@ckeditor/ckeditor5-essentials' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-essentials.js', + '@ckeditor/ckeditor5-find-and-replace' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-find-and-replace.js', + '@ckeditor/ckeditor5-font' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-font.js', + '@ckeditor/ckeditor5-fullscreen' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-fullscreen.js', + '@ckeditor/ckeditor5-heading' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-heading.js', + '@ckeditor/ckeditor5-highlight' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-highlight.js', + '@ckeditor/ckeditor5-horizontal-line' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-horizontal-line.js', + '@ckeditor/ckeditor5-html-support' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-html-support.js', + '@ckeditor/ckeditor5-icons' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-icons.js', + '@ckeditor/ckeditor5-image' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-image.js', + '@ckeditor/ckeditor5-indent' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-indent.js', + '@ckeditor/ckeditor5-inspector' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-inspector.js', + '@ckeditor/ckeditor5-language' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-language.js', + '@ckeditor/ckeditor5-link' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-link.js', + '@ckeditor/ckeditor5-list' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-list.js', + '@ckeditor/ckeditor5-mention' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-mention.js', + '@ckeditor/ckeditor5-paragraph' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-paragraph.js', + '@ckeditor/ckeditor5-paste-from-office' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-paste-from-office.js', + '@ckeditor/ckeditor5-remove-format' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-remove-format.js', + '@ckeditor/ckeditor5-select-all' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-select-all.js', + '@ckeditor/ckeditor5-show-blocks' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-show-blocks.js', + '@ckeditor/ckeditor5-source-editing' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-source-editing.js', + '@ckeditor/ckeditor5-special-characters' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-special-characters.js', + '@ckeditor/ckeditor5-style' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-style.js', + '@ckeditor/ckeditor5-table' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-table.js', + '@ckeditor/ckeditor5-theme-lark' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-theme-lark.js', + '@ckeditor/ckeditor5-typing' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-typing.js', + '@ckeditor/ckeditor5-ui' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-ui.js', + '@ckeditor/ckeditor5-undo' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-undo.js', + '@ckeditor/ckeditor5-upload' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-upload.js', + '@ckeditor/ckeditor5-utils' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-utils.js', + '@ckeditor/ckeditor5-watchdog' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-watchdog.js', + '@ckeditor/ckeditor5-widget' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-widget.js', + '@ckeditor/ckeditor5-word-count' => 'EXT:rte_ckeditor/Resources/Public/Contrib/@ckeditor/ckeditor5-word-count.js', + ], +]; diff --git a/Configuration/RTE/Default.yaml b/Configuration/RTE/Default.yaml new file mode 100644 index 0000000..5437fab --- /dev/null +++ b/Configuration/RTE/Default.yaml @@ -0,0 +1,77 @@ +# Load default processing options +imports: + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Processing.yaml' } + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/Base.yaml' } + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/Plugins.yaml' } + #- { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/LinkBrowser.yaml' } +# Additional optional TYPO3 specific configuration is available via ./Editor/LinkBrowser.yaml +# See https://docs.typo3.org/c/typo3/cms-rte-ckeditor/main/en-us/Configuration/Reference.html +# +# The keys typing.transformations.extra.from and htmlSupport.allow.name allow +# to have a special array with a key `pattern` that allows to specify Regular Expressions: +# - { from: { pattern: '(typoscript|TYPOScript|typo3script)$' }, to: 'TypoScript' } +# see https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/12.4.x/Important-104827-AllowToUseRegularExpressionsInCKEditorYAML.html +# +# Add configuration for the editor +# For complete documentation see https://ckeditor.com/docs/ckeditor5/latest/features/index.html +editor: + config: + toolbar: + items: + - style + - heading + # grouping separator + - '|' + - bold + - italic + - subscript + - superscript + - softhyphen + - '|' + - bulletedList + - numberedList + - blockQuote + - alignment + - '|' + - findAndReplace + - link + - '|' + - removeFormat + - undo + - redo + - '|' + - insertTable + - '|' + - specialCharacters + - horizontalLine + - sourceEditing + + heading: + options: + - { model: 'paragraph', title: 'Paragraph' } + - { model: 'heading2', view: 'h2', title: 'Heading 2' } + - { model: 'heading3', view: 'h3', title: 'Heading 3' } + - { model: 'formatted', view: 'pre', title: 'Pre-Formatted Text' } + + style: + definitions: + - { name: "Lead", element: "p", classes: ['lead'] } + - { name: "Small", element: "small" } + - { name: "Muted", element: "span", classes: ['text-muted'] } + + alignment: + options: + - { name: 'left', className: 'text-start' } + - { name: 'center', className: 'text-center' } + - { name: 'right', className: 'text-end' } + - { name: 'justify', className: 'text-justify' } + + table: + defaultHeadings: { rows: 1 } + contentToolbar: + - tableColumn + - tableRow + - mergeTableCells + - tableProperties + - tableCellProperties + - toggleTableCaption diff --git a/Configuration/RTE/Editor/Base.yaml b/Configuration/RTE/Editor/Base.yaml new file mode 100644 index 0000000..9171455 --- /dev/null +++ b/Configuration/RTE/Editor/Base.yaml @@ -0,0 +1,14 @@ +# Add configuration for the editor for any configuration +# For complete documentation see http://docs.ckeditor.com/#!/api/CKEDITOR.config +editor: + config: + # the CSS file to be used inside the editor + contentsCss: + - 'EXT:rte_ckeditor/Resources/Public/Css/contents.css' + height: 300 + width: 'auto' + ui: + poweredBy: + position: 'inside' + side: 'right' + label: '' diff --git a/Configuration/RTE/Editor/LinkBrowser.yaml b/Configuration/RTE/Editor/LinkBrowser.yaml new file mode 100644 index 0000000..8c88279 --- /dev/null +++ b/Configuration/RTE/Editor/LinkBrowser.yaml @@ -0,0 +1,98 @@ +# Additional TYPO3 specific configuration comes here. +# See https://docs.typo3.org/c/typo3/cms-rte-ckeditor/main/en-us/Configuration/Reference.html +# This is unrelated to CKEditor itself, but applies to Link Browser usage: + +# Which fields can be shown with additional attributes for a link? (comma separated list) +# - target: Link target (allows to use "_blank", "_self", "_top" or a framename) +# - title: Link title attribute (not the link text itself) +# - class: Custom CSS class selection +# - params: Additional URL query arguments (see "queryParametersSelector" below when used) +# - rel: Link relation attribute ("rel", see "relAttribute" below when used) +allowedOptions: 'target,title,class,params,rel' + +# Comma-separated list of allowed Link Types +# Valid LinkTypes: +# - page: Internal TYPO3 page +# - url: External URL +# - file: TYPO3 file relation +# - folder: TYPO3 folder relation +# - email: Mail address +# - ...: Custom Link Types, when implemented, with their name. +allowedTypes: 'page,url,file,folder,telephone,email' + +# Which class definitions and targets are allowed per LinkType (array) +# Each array element has attributes: +# - "class": Default CSS class to apply to this LinkType +# - "type": LinkType as listed above +# - "target": Default link target to apply to this LinkType +classesAnchor: + - { class: "customPageCssClass", type: "page", target: "" } + - { class: "customUrlCssClass", type: "url", target: "_blank" } + - { class: "customFileCssClass", type: "file", target: "_parent" } + - { class: "customFolderCssClass", type: "folder" } + - { class: "customTelephoneCssClass", type: "telephone" } + - { class: "customEmailCssClass", type: "email" } + +buttons: + link: + # Global Link Browser options + options: + # Optional comma-separated list of Link Types to be specifically removed + removeItems: 'telephone' + # Specifically enable the "rel" attribute entry, needed when set in "allowedOptions" + relAttribute: + enabled: true + # Specifically enable the "params" (URL query arguments) attribute entry, needed when set in "allowedOptions" + queryParametersSelector: + enabled: true + # Optionally disable showing the "target" field (even though when set in "allowedOptions") + targetSelector: + disabled: false + + # Define general CSS options for the Link Browser + # This is needed to perform any of the link classing below! + properties: + class: + # If set to "true", a CSS class must be selected + required: false + # Available CSS classes + allowedClasses: 'globalCss1,globalCss2,customPageCssClass,customUrlCssClass,customFileCssClass,customFolderCssClass,customTelephoneCssClass,customEmailCssClass' + + # Define default CSS classes per LinkType. The CSS class name listed here + # must also be part of the "allowedClasses" attribute above. + # The attribute "required" is also available for each block. + page: + properties: + class: + default: "customPageCssClass" + url: + properties: + class: + default: "customUrlCssClass" + file: + properties: + class: + default: "customFileCssClass" + folder: + properties: + class: + default: "customFolderCssClass" + telephone: + properties: + class: + default: "customTelephoneCssClass" + email: + properties: + class: + default: "customEmailCssClass" + +# Optionally define labels for all classes listed in buttons.link.properties.class.allowedClasses +# Also the style of a CSS class choice can be applied via "value" (deprecated, does not work in all browsers). +# Any label missing in this setup just uses the CSS classname as a label, without special formatting. +classes: + globalCss1: + name: Label for globalCss1 + customPageCssClass: + name: Label for customPageCssClass + value: "color: red; background-color: blue" + diff --git a/Configuration/RTE/Editor/Plugins.yaml b/Configuration/RTE/Editor/Plugins.yaml new file mode 100644 index 0000000..a86faed --- /dev/null +++ b/Configuration/RTE/Editor/Plugins.yaml @@ -0,0 +1,22 @@ +# Register custom plugins for ckeditor +editor: + config: + # Explicitly remove (default or external) plugins + # removeImportModules: [] + + # load modules for plugins when CKEditor is initialized + # see CKEditor plugin API for details + importModules: + # Plugin for whitespace control like soft hypens and non breaking spaces + - { module: '@typo3/rte-ckeditor/plugin/whitespace.js', exports: [ 'Whitespace' ] } + - { module: '@typo3/rte-ckeditor/plugin/typo3-link.js', exports: [ 'Typo3Link' ] } + + # Configure global wordCount plugin defaults + # Overwrite them in your RTE presets as it is necessary + # wordCount: + # displayWords: true + # displayCharacters: true + + # configuration for external plugins + externalPlugins: + typo3link: { route: 'rteckeditor_wizard_browse_links' } diff --git a/Configuration/RTE/Full.yaml b/Configuration/RTE/Full.yaml new file mode 100644 index 0000000..6661033 --- /dev/null +++ b/Configuration/RTE/Full.yaml @@ -0,0 +1,167 @@ +#### +# Example of what CKEditor can all bring +### + +# Load default processing options +imports: + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Processing.yaml' } + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/Base.yaml' } + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/Plugins.yaml' } + +# Add configuration for the editor +# For complete documentation see https://ckeditor.com/docs/ckeditor5/latest/features/index.html +editor: + config: + style: + definitions: + # block level styles + - { name: 'Orange title H2', element: 'h2', classes: ['orange'] } + - { name: 'Orange title H3', element: 'h3', classes: ['orange'] } + - { name: 'Quote / Citation', element: 'blockquote' } + - { name: 'Code block', element: 'code' } + # Inline styles + - { name: 'Yellow marker', element: 'span', classes: ['yellow-marker'] } + + heading: + options: + - { model: 'paragraph', title: 'Paragraph' } + - { model: 'heading2', view: 'h2', title: 'Heading 2' } + - { model: 'heading3', view: 'h3', title: 'Heading 3' } + - { model: 'formatted', view: 'pre', title: 'Pre-Formatted Text' } + + toolbar: + items: + - removeFormat + - undo + - redo + # grouping separator + - '|' + - findAndReplace + - link + - '|' + - insertTable + - tableColumn + - tableRow + - mergeTableCells + - TableProperties + - TableCellProperties + - '|' + - selectAll + - '|' + - sourceEditing + - showBlocks + - horizontalLine + # line break + - '-' + - bold + - italic + - underline + - strikethrough + - subscript + - superscript + - softhyphen + - '|' + - bulletedList + - numberedList + - blockQuote + - indent + - outdent + - alignment + - '|' + - specialCharacters + - '-' + - style + - heading + - fontFamily + - fontBackgroundColor + - fontColor + - fontSize + - textPartLanguage + - highlight + - highlight:greenMarker + - '|' + - fullscreen + + alignment: + options: + - { name: 'left', className: 'text-start' } + - { name: 'center', className: 'text-center' } + - { name: 'right', className: 'text-end' } + - { name: 'justify', className: 'text-justify' } + + table: + defaultHeadings: { rows: 1 } + contentToolbar: + - tableColumn + - tableRow + - mergeTableCells + - tableProperties + - tableCellProperties + - toggleTableCaption + + fontColor: + colors: + - { label: 'Orange', color: '#ff8700' } + - { label: 'Blue', color: '#0080c9' } + - { label: 'Green', color: '#209d44' } + + fontBackgroundColor: + colors: + - { label: 'Stage orange light', color: '#fab85c' } + + fontFamily: + options: + - 'default' + - 'Arial, sans-serif' + + fontSize: + options: + - 'default' + - 18 + - 21 + + list: + properties: + styles: true + startIndex: true + reversed: true + + indentBlock: + classes: + - 'ps-2' + - 'ps-3' + - 'ps-4' + + language: + textPartLanguage: [ + { title: 'English', languageCode: 'en' }, + { title: 'French', languageCode: 'fr' }, + { title: 'German', languageCode: 'de' } + ] + + highlight: + options: + - { model: 'yellowMarker', class: 'marker-yellow', title: 'Yellow marker', type: 'marker', color: 'var(--ck-content--highlight-marker-yellow)' } + - { model: 'greenMarker', class: 'marker-green', title: 'Green marker', type: 'marker', color: 'var(--ck-content-highlight-marker-green)' } + - { model: 'redPen', class: 'pen-red', title: 'Red pen', type: 'pen', color: 'var(--ck-content-highlight-pen-red)' } + + mention: + feeds: + - + marker: '@' + feed: + - '@TYPO3' + minimumCharacters: 1 + + importModules: + - { module: '@ckeditor/ckeditor5-word-count', exports: ['WordCount'] } + # Provides fontFamily, fontSize, fontColor, and fontBackgroundColor toolbar items + - { module: '@ckeditor/ckeditor5-font', exports: ['Font'] } + # Provides showBlocks toolbar item + - { module: '@ckeditor/ckeditor5-show-blocks', exports: ['ShowBlocks'] } + # Provides textPartLanguage toolbar item + - { module: '@ckeditor/ckeditor5-language', exports: ['TextPartLanguage'] } + - { module: '@ckeditor/ckeditor5-mention', exports: ['Mention'] } + - { module: '@ckeditor/ckeditor5-highlight', exports: ['Highlight'] } + - { module: '@ckeditor/ckeditor5-list', exports: ['ListProperties'] } + - { module: '@ckeditor/ckeditor5-fullscreen', exports: [ 'Fullscreen' ] } diff --git a/Configuration/RTE/Minimal.yaml b/Configuration/RTE/Minimal.yaml new file mode 100644 index 0000000..b808e75 --- /dev/null +++ b/Configuration/RTE/Minimal.yaml @@ -0,0 +1,16 @@ +# Load default processing options +imports: + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Processing.yaml' } + - { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/Base.yaml' } + +# Minimal configuration for the editor +editor: + config: + toolbar: + items: + - bold + - italic + - '|' + - clipboard + - undo + - redo diff --git a/Configuration/RTE/Processing.yaml b/Configuration/RTE/Processing.yaml new file mode 100644 index 0000000..295ac7d --- /dev/null +++ b/Configuration/RTE/Processing.yaml @@ -0,0 +1,106 @@ +# ******************************************************** +# Sets the proc options for all default configurations +# https://docs.typo3.org/permalink/t3tsref:rte-config-proc +# ******************************************************** + +processing: + mode: default + # Tags that are allowed in the content in general + # Note that some tags like require you to style them via custom CSS directives in your frontend. + allowTags: + - a + - abbr + - acronym + - address + - article + - big + - blockquote + - br + - caption + - cite + - code + - col + - colgroup + - dd + - del + - dfn + - dl + - div + - dt + - em + - figure + - figcaption + - footer + - header + - h1 + - h2 + - h3 + - h4 + - h5 + - h6 + - hr + - i + - img + - ins + - kbd + - label + - li + - mark + - nav + - ol + - p + - pre + - q + - s + - samp + - section + - small + - span + - strong + - sub + - sup + - table + - thead + - tbody + - tfoot + - td + - th + - tr + - tt + - u + - ul + - var + + ## Tags that are allowed outside of paragraphs + allowTagsOutside: [address, article, aside, blockquote, figure, figcaption, footer, header, hr, mark, nav, section, div] + + ## allowed default attributes + allowAttributes: [class, id, title, dir, lang, xml:lang, itemscope, itemtype, itemprop] + + ## CONTENT TO DATABASE + HTMLparser_db: + ## STRIP ALL ATTRIBUTES FROM THESE TAGS + ## If this list of tags is not set, it will default to: b,i,u,br,center,hr,sub,sup,strong,em,li,ul,ol,blockquote,strike,mark,s. + ## However, we want to keep xml:lang attribute on most tags and tags from the default list were cleaned on entry. + noAttrib: br + # Can be disabled if you trust ckeditor (If Automatic Content Formatting is enabled, this should be OK) + # allowTags: %default% + denyTags: img + tags: + hr: + allowedAttribs: + - class + + ## REMOVE OPEN OFFICE META DATA TAGS, WORD 2003 TAGS, LINK, META, STYLE AND TITLE TAGS, AND DEPRECATED HTML TAGS + ## We use this rule instead of the denyTags rule so that we can protect custom tags without protecting these unwanted tags. + removeTags: [link, meta, o:p, sdfield, style, title, font, center] + + ## PROTECT CUSTOM TAGS + keepNonMatchedTags: protect + + # HTML Sanitizer + # `htmlSanitize = false | null` to disable individually + htmlSanitize: + # either preset name as declared in `$GLOBALS['TYPO3_CONF_VARS']['SYS']['htmlSanitizer']` + # or class-name implementing interface `\TYPO3\HtmlSanitizer\Builder\BuilderInterface` + build: default diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml new file mode 100644 index 0000000..bdfce1a --- /dev/null +++ b/Configuration/Services.yaml @@ -0,0 +1,8 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + TYPO3\CMS\RteCKEditor\: + resource: '../Classes/*' diff --git a/Documentation/Configuration/BestPractices.rst b/Documentation/Configuration/BestPractices.rst new file mode 100644 index 0000000..3159c01 --- /dev/null +++ b/Documentation/Configuration/BestPractices.rst @@ -0,0 +1,83 @@ +.. include:: /Includes.rst.txt + + +.. _config-best-practices: + +============================ +Configuration Best Practices +============================ + +.. _best-practice-sitepackage: + +Use a Sitepackage extension +=========================== + +It is generally recommended to use a sitepackage extension to +customize a TYPO3 website. The sitepackage contains configuration files +for that site. + +See the :doc:`TYPO3 Sitepackage Tutorial ` on how +to create a sitepackage. We assume here your sitepackage extension has the +key `my_sitepackage`. + +The YAML preset files should be kept in folder +:file:`EXT:my_sitepackage/Configuration/RTE/`. + +RTE configurations need to be registered in your sitepackages +:file:`ext_localconf.php`: + +.. code-block:: php + :caption: EXT:my_sitepackage/ext_localconf.php + + $GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['myconfig'] + = 'EXT:my_sitepackage/Configuration/RTE/MyConfiguration.yaml'; + +.. note:: + It is possible but not recommended to define this setting in the projects + :file:`system/settings.php` or :file:`system/additional.php` + +.. _best-practice-boilerplate: + +Use TYPO3’s Core Default.yaml as boilerplate +============================================ + +It is recommended to start by copying the file +:file:`typo3/sysext/rte_ckeditor/Configuration/RTE/Default.yaml` into your +sitepackage to the file +:file:`EXT:my_sitepackage/Configuration/RTE/MyConfiguration.yaml`. + + +Check TYPO3's Core Full.yaml to gain insight into a more extensive configuration +================================================================================ + +This preset shows more configured options and plugins. It is not intended for real use. +It acts as an example. + +:file:`typo3/sysext/rte_ckeditor/Configuration/RTE/Full.yaml` + + +Use Core includes +================= + +It is recommended to use the following includes at the top of your custom +configuration: + +.. code-block:: yaml + :caption: EXT:my_sitepackage/Configuration/RTE/MyConfiguration.yaml + + imports: + - { resource: "EXT:rte_ckeditor/Configuration/RTE/Processing.yaml" } + - { resource: "EXT:rte_ckeditor/Configuration/RTE/Editor/Base.yaml" } + - { resource: "EXT:rte_ckeditor/Configuration/RTE/Editor/Plugins.yaml" } + +If you started out by copying this extensions +:ref:`Default.yaml as boilerplate ` the imports +should already be there. + +The include files are already split up so the processing transformations can +just be included or even completely disabled (by removing the line for importing). + +.. attention:: + Please be aware that removing the :file:`Processing.yaml` removes + security measures. In that case you have to take care of keeping the ckeditor + safe yourself. diff --git a/Documentation/Configuration/Concepts.rst b/Documentation/Configuration/Concepts.rst new file mode 100644 index 0000000..a8b179e --- /dev/null +++ b/Documentation/Configuration/Concepts.rst @@ -0,0 +1,350 @@ +.. include:: /Includes.rst.txt + +.. _config-concepts: + +====================== +Configuration Concepts +====================== + + +Configuration Overview +====================== + +The main principles of configuring a Rich Text Editor in TYPO3 +apply to editing with any Rich Text Editor (`rte_ckeditor`, ...). + +Some of the functionality (for example the RTE transformations) is +embedded in the TYPO3 core and not specific to `rte_ckeditor`. + +There are three main parts relevant for rich text editing with TYPO3: + +Editor configuration + This covers how the actual editor (in this case CKEditor) should behave, + what buttons should be shown, what options are available. + +RTE transformations + This defines how the information is processed when saved from the Rich Text Editor to the database. + And when loaded from the database into the Rich Text Editor. + +Frontend output configuration + The information fetched from the database may need to be processed for the frontend. + The configuration of the frontend output is configured via TypoScript. + +.. todo: diagram: overview with DB <-> RTE, DB -> FE etc. + +This section mainly covers editor configuration and RTE transformations, as for +TypoScript the TypoScript reference handles output of HTML content and +has everything preset (see :ref:`t3tsref:parsefunc`). + + +.. tip:: + Before you start, have a look at the :ref:`config-best-practices`. + + +.. _config-editor: + +Editor Configuration +==================== + +YAML +---- + +TYPO3 is using a custom :ref:`YAML API ` for handling YAML +in TYPO3 based on the Symfony YAML package. Therefore environment variables +can be used. + +YAML Basics +~~~~~~~~~~~ + +* YAML is case sensitive +* Indenting level reflects hierarchy level and indenting must be used consistently + (indent with 2 spaces in `rte_ckeditor` configuration). +* Comments begin with a `#`. +* White space is important, use a space after `:`. + +This is a dictionary (associative array): + +.. code-block:: yaml + + key1: value + key2: value + +A dictionary can be nested, for example: + +.. code-block:: yaml + + key1: + key1-2: value + +This is a list: + +.. code-block:: yaml + + - list item 1 + - list item 2 + +A dictionary can be combined with a list: + +.. code-block:: yaml + + key: + key2: + - item 1 + - item 2 + + +.. _configuration-presets: + +Configuration Presets +--------------------- + +Presets are the heart of having custom configuration per record type, or +page area. A preset consists of a name and a reference to the location +of a YAML file. + +TYPO3 ships with three RTE presets, “default”, “minimal” and “full”. The +"default" configuration is active by default. + +It is possible for extensions to ship their own preset like “news”, or “site_xyz”. + +Registration of a preset happens within :file:`system/config.php`, +:file:`system/additional.php` or within +:file:`ext_localconf.php` of an extension: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['default'] + = 'EXT:rte_ckeditor/Configuration/RTE/Default.yaml'; + +This way, it is possible to override the default preset, for example by using +the configuration defined in a custom extension: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['default'] + = 'EXT:my_extension/Configuration/RTE/Default.yaml'; + + +TYPO3 uses the “default” preset for all Rich-Text-Element fields. To use +a different preset throughout an installation or a branch of the website, +see :ref:`override-configuration-via-page-tsconfig`. + +Selecting a specific preset for bullet lists can be done via TCA +configuration of a field. The following example shows the TCA configuration +for the sys_news database table, which can be found in +:file:`EXT:core/Configuration/TCA/sys_news.php`. + +.. code-block:: php + + 'content' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.text', + 'config' => [ + 'type' => 'text', + 'cols' => 48, + 'rows' => 5, + 'enableRichtext' => true, + 'richtextConfiguration' => 'default', + ], + ], + +Enabling Rich Text Parsing itself is done via :ref:`t3tca:confval-text-enablerichtext`, +and a specific configuration +can be set via :ref:`t3tca:confval-text-richtextConfiguration`, setting it to for example +“news”. + +.. _override-configuration-via-page-tsconfig: + +Overriding Configuration via page TSconfig +------------------------------------------ + +Instead of overriding all TCA fields to use a custom preset, it is possible +to override this information via page TSconfig. + +The option :typoscript:`RTE.default.preset = news` can also be set on a per-field +and per-type basis: + +.. code-block:: tsconfig + :caption: EXT:my_sitepackage/Configuration/page.tsconfig + :linenos: + + # per-field + RTE.config.tt_content.bodytext.preset = minimal + + # per-type + RTE.config.tt_content.bodytext.types.bullets.preset = bullets + +line #2 + This sets the "minimal" preset for all bodytext fields of content elements. + +line #4 + This sets the "bullets" preset for all bodytext fields of content elements, + with Content Type “Bullet list” (CType=bullets). + +Of course, any other specific option set via YAML can be overridden via Page TSconfig as well: + +Specific options set via YAML can be overridden via page TSconfig as well - but +be aware that boolean values can not be set, and arrays are not merged but +overridden. + +.. code-block:: tsconfig + :caption: EXT:my_sitepackage/Configuration/page.tsconfig + + # Restrict format_tags to h2 in bodytext field of content elements + RTE.config.tt_content.bodytext.editor.config.format_tags = h2 + +The loading order for configuration is: + +#. ``preset`` defined for a specific field via PageTS +#. ``richtextConfiguration`` defined for a specific field via TCA +#. general preset defined via page TSconfig +#. ``default`` + + +For more examples, see :ref:`t3tsref:pageTsRte` in "TSconfig Reference". + + +.. _config-rte-transformations: + +RTE Transformations +=================== + +Transformations are directives for parsing HTML markup. They are executed by the +TYPO3 Core every time a RTE-based field is saved to the TYPO3 database or fetched +from the database for the Rich Text Editor to render. This way, there are always +two ways / two transformations applied. + +There are several advantages for transformations, the most prominent reason is to +not inject bad HTML code into the database which in turn would be used for output. +Transformations from the RTE towards the database can filter out HTML tags or attributes. + +You can read more about +:ref:`RTE Transformations in TYPO3 Explained `. + +.. todo: diagram rte -> DB -> RTE + +A Brief Dive Into History +------------------------- + +Back in the very old days of TYPO3, there was an RTE which only worked inside Microsoft +Internet Explorer 4 (within the system extension “`rte`”). All other editors of TYPO3 had +to write HTML by hand, which was very complicated with all the table-based layouts available. +Links were not set with a :html:`` tag, but with a so-called :html:`` +tag. Further tags were :html:`` and :html:``, which were stored in the database +1:1. Since RTEs did not understand these special tags, they had to transform these special tags into +valid HTML tags. Additionally, TYPO3 did not store regular :html:`

` or :html:`

` tags but +treated every line without a surrounding HTML block element as :html:`

` tag. The frontend rendering +then added `

` tags for each line when parsing (see below). + +Transformations were later used to allow :html:``/:html:`` tags instead of :html:``/:html:`` +tags, while staying backwards-compatible. + +A lot of transformation options have been dropped for TYPO3 v8, and the default configuration +for these transformations acts as a solid base. CKEditor itself includes features that work as +another security layer for disallowing injecting of certain HTML tags in the database. + +For TYPO3 v8, the :html:`` tag was migrated to proper :html:`` tags with a special +:html:`` syntax when linking to pages to ensure HTML valid output. +Additionally, all records that are edited and stored to the database now contain proper +

tags, and transformations for paragraph tags are only applied when not set yet. + +Transformations for invalid links and images (still available in HtmlArea) are still in place. + +Most logic related to transformations can be found within :php:`TYPO3\CMS\Core\Html\RteHtmlParser`. + + +.. _transformations-vs-acf: + +Transformations vs. CKEditor’s Advanced Content Filter +------------------------------------------------------ + +TYPO3’s HtmlParser transformations were used to transform readable semi-HTML +code to a full-blown HTML rendering ready for the RTE and vice versa. Since +TYPO3 v8, magically adding :html:`

` tags or transforming :html:`` +tags is not necessary anymore, which leaves transformations almost obsolete. + +However, they can act as an extra fallback layer of security to filter out +disallowed tags when saving. TYPO3 v8 configuration ships with a generic +transformation configuration, which is mainly based on legacy functionality +shipped with TYPO3 nowadays. + +However, CKEditor comes with a separate strategy of allowing which HTML tags +and attributes are allowed, and can be configured on an editor-level. +This configuration option is called “allowedContent”, the feature itself is +named `Advanced Content Filter `__ +(ACF). + +Activating CKEditor’s table plugin allows to add :html:``, :html:`` +tags etc. Enabling the link picker enables the usage of :html:`` tags. CKEditor +cleans content right away which was e.g. copy-pasted from MS Word and does not +match the allowed tags. + + +.. _config-frontend: + +Frontend Output Configuration +============================= + +Mostly due to historical reasons, the frontend output added :html:`

` tags to each +line which is not wrapped in HTML. Additionally the :html:`` tag was replaced +by :html:`` tags and checked if e.g. if a link was set to a specific page within +TYPO3 is actually accessible for this specific visitor. + +The latter part is still necessary, so the :html:`` HTML snippet +is replaced by a speaking URL which the power of typolink will still take care of. +There are, of course, more options to it, like default “target” attributes for +external links or spam-protecting links to email addresses, which all happens within the +typolink logic, the master for generating a link in the TYPO3 Frontend rendering process. + +.. todo: [DIAGRAM DB => FE] + + +TypoScript +---------- + +As with every content that is rendered via TYPO3, this processing for the frontend +output of Rich-Text-Editing fields is done via TypoScript, more specifically within +the stdWrap property :ref:`t3tsref:parsefunc`. With Fluid Styled Content and CSS Styled +Content comes :typoscript:`lib.parseFunc` and :typoscript:`lib.parseFunc_RTE` which add +support for parsing :html:`` and :html:`` tags and dumping them into the typolink +functionality. The shipped TypoScript code looks like this: + +.. code-block:: typoscript + + lib.parseFunc.tags { + a = TEXT + a { + current = 1 + typolink { + parameter.data = parameters:href + title.data = parameters:title + ATagParams.data = parameters:allParams + target.data = parameters:target + extTarget = {$styles.content.links.extTarget} + extTarget.override.data = parameters:target + } + } + } + + +If you already use Fluid Styled Content and CSS Styled Content and +you haven’t touched that area of TypoScript yet, you’re good to go +by including the TypoScript file. + +Fluid +----- + +Outputting the contents of a RTE-enabled database field within Fluid can +be achieved by adding :html:`{record.myfield -> f:format.html()}` +which in turn calls :typoscript:`stdWrap.parseFunc` with :typoscript:`lib.parseFunc_RTE` +thus applying the same logic. Just ensure that the :typoscript:`lib.parseFunc_RTE` +functionality is available. + +You can check if this TypoScript snippet is loaded by using +:guilabel:`Sites > TypoScript` and use the TypoScript Tree (Setup) +to see if :typoscript:`lib.parseFunc_RTE` is filled. + +.. todo: [SCREENSHOT of TSOB having lib.parseFunc_RTE open] + +.. important:: + Take care of where you add opening and closing tags, if you don't use the fluid inline notation. + If they are on an own line, the rendered output includes empty paragraphs at beginning and end. diff --git a/Documentation/Configuration/ConfigureTypo3.rst b/Documentation/Configuration/ConfigureTypo3.rst new file mode 100644 index 0000000..8e80fc0 --- /dev/null +++ b/Documentation/Configuration/ConfigureTypo3.rst @@ -0,0 +1,178 @@ +.. include:: /Includes.rst.txt +.. highlight:: typoscript + +.. _config-typo3: + +========================== +TYPO3 Configuration Basics +========================== + +Just in case you are not familiar with how to configure TYPO3, we will +give you a very brief introduction. Otherwise, you can safely +skip this part and continue reading +:ref:`config-concepts`. + +We only cover configuration methods that are used to configure `rte_ckeditor`. + +.. _config-typo3-page-tsconfig: + +Page TSconfig +============= + +We recommend you to put all configurations for the preset in the +:ref:`YAML ` configuration. However, it is still possible to +override these settings through the page TSconfig. + +You can find a list of configuration properties in the :ref:`Page TSconfig +reference, chapter RTE `. + +Relevant Settings for `rte_ckeditor` +------------------------------------ + +Page TSconfig can be used to change: + +#. Default preset: + + .. code-block:: tsconfig + + RTE.default.preset = full + +#. Override for one field (:typoscript:`RTE.config.[tableName].[fieldName].preset`): + + .. code-block:: tsconfig + + RTE.config.tt_content.bodytext.preset = myCustomPreset + RTE.config.tx_news_domain_model_news.bodytext.preset = minimal + +#. Override for one field defined in flexform (:typoscript:`RTE.config.[tableName].[flexForm\.field\.name].preset`): + + .. code-block:: tsconfig + + RTE.config.tt_content.settings\.notifications\.emailText.preset = myCustomPreset + +#. Override for one field, if type matches (:typoscript:`RTE.config.[tableName].[fieldName].types.[type].preset`): + + .. code-block:: tsconfig + + RTE.config.tt_content.bodytext.types.textmedia.preset = minimal + + +How to change values +-------------------- + +See the :ref:`Page TSconfig reference, +chapter Setting Page TSconfig `. This chapter +also explains how to verify the settings. + +.. _config-typo3-global-configuration: + +Global Configuration +==================== + +Global Configuration is a system-wide general configuration. + +Relevant Settings for `rte_ckeditor` +------------------------------------ + +The setting :php:`$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']` is used to configure +the available presets for rich text editing. + +By default, the presets "minimal", "default" and "full" are defined. + +If you add a new preset, you must add it to this array. + + +How to change values +-------------------- + +Usually, Global Configuration can be configured in the backend in +:guilabel:`System > Settings > Configure Installation-Wide Options`. + +However, the settings relevant for rich text editing, :php:`$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']` +cannot be configured in the backend. + +You must either configure this in: + +#. The file :file:`%config-dir%/system/additional.php` +#. Or in an extension in the file :file:`EXT:/ext_localconf.php` + +.. code-block:: php + + if (empty($GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['myCustomPreset'])) { + $GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['myCustomPreset'] + = 'EXT:/Configuration/RTE/MyCustomPreset.yaml'; + } + + +How to view settings +-------------------- + +You can view the Global Configuration in +:guilabel:`System > Configuration > $GLOBAL['TYPO3_CONF_VARS'] (Global Configuration) > RTE`. + +.. figure:: images/global-configuration-rte.png + :class: with-shadow + + Global Configuration: RTE > Presets + + + +.. _config-typo3-yaml: + +YAML +==== + +Most of the configuration of `rte_ckeditor` will be done in a YAML file. + + +Relevant Settings for `rte_ckeditor` +------------------------------------ + +See :ref:`config-ref` + +How to change values +-------------------- + +This is done directly in the file. The YAML file should be included in a +sitepackage extension, see :ref:`best-practice-sitepackage`. + + +.. _config-typo3-tca: + +CKEditor related TCA configuration +================================== + +The :abbr:`table configuration array (TCA)` is used to configure database fields and how they will behave in the +backend when edited. It is for example used to define that ``tt_content.bodytext`` should be edited +with a rich text editor. + + +Relevant Settings for `rte_ckeditor` +------------------------------------ + +* :ref:`t3tca:confval-text-enablerichtext` +* :ref:`t3tca:confval-text-richtextConfiguration` + +How to change values +-------------------- + +This must be done in an extension in :file:`Configuration/TCA`. Usually this is done within a custom sitepackage +extension, see :ref:`best-practice-sitepackage`. + +How to view settings +-------------------- + +You can view TCA in the backend: +:guilabel:`System > Configuration > $GLOBAL['TCA'] (Table configuration array)`. + +For example, look at :guilabel:`tt_content > columns > bodytext`. + +However, you will +find that neither `enableRichtext`, nor `richtextConfiguration` is set here. They +are configured in :guilabel:`tt_content > types` for various content types, for example +look at :guilabel:`tt_content > types > text > columnsOverrides`. + +.. figure:: images/column_overrides.png + :class: with-shadow + + TCA: tt_content > types > text > columnsOverrides > bodytext diff --git a/Documentation/Configuration/Examples.rst b/Documentation/Configuration/Examples.rst new file mode 100644 index 0000000..20a9d87 --- /dev/null +++ b/Documentation/Configuration/Examples.rst @@ -0,0 +1,256 @@ +.. include:: /Includes.rst.txt + +.. _config-examples: + +====================== +Configuration Examples +====================== + +How do I use a different preset? +================================ + +Instead of using the default "default" preset, you can change this, for example +to "full", using **page TSconfig**: + +.. code-block:: tsconfig + :caption: EXT:my_sitepackage/Configuration/page.tsconfig + + RTE.default.preset = full + +Of course, the preset must already exist, or you must define it. `rte_ckeditor` +ships with presets "minimal", "default" and "full". + +Additionally, you can set specific presets for specific types of textfields. + +For example to use preset "full" for the field "bodytext" of all content elements: + +.. code-block:: tsconfig + :caption: EXT:my_sitepackage/Configuration/page.tsconfig + + RTE.config.tt_content.bodytext.preset = full + +To use preset "minimal" for the field "bodytext" of only content elements +with ctype="text": + +.. code-block:: tsconfig + :caption: EXT:my_sitepackage/Configuration/page.tsconfig + + RTE.config.tt_content.bodytext.types.text.preset = minimal + +For more examples, see :ref:`t3tsref:pageTsRte` in "TSconfig Reference". + + +How do I create my own preset? +============================== + +In your sitepackage extension: + +In :file:`ext_localconf.php`, replace `my_extension` with your extension key, replace `my_preset` and `MyPreset.yaml` +with the name of your preset. + +.. code-block:: php + :caption: EXT:my_sitepackage/ext_localconf.php + + $GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['my_preset'] + = 'EXT:my_extension/Configuration/RTE/MyPreset.yaml'; + +In :file:`Configuration/RTE/MyPreset.yaml`, create your configuration, for example: + +.. literalinclude:: _Examples/_MyPreset.yaml + :language: yaml + :caption: EXT:my_sitepackage/Configuration/RTE/MyPreset.yaml + +See also the note for :option:`editor.config.contentsCss`. + +How do I customize the toolbar? +=============================== + +The toolbar can be customized individually by configuring required toolbar +items in the YAML configuration. The following configuration shows the toolbar +configuration of the minimal editor setup included in file +:file:`EXT:rte_ckeditor/Configuration/RTE/Minimal.yaml`: + +.. literalinclude:: _Examples/_CustomizeToolbar.yaml + :language: yaml + :caption: EXT:my_sitepackage/Configuration/RTE/MyPreset.yaml + +The :yaml:`'|'` can be used as a separator between groups of toolbar items. + +Additional configuration options are available in the official CKEditor 5 +`Toolbar documentation `__ + +.. _config-example-toolbargrouping: + +Grouping toolbar items in drop-downs +------------------------------------ + +To save space in the toolbar or to arrange the features thematically, it is +possible to group several items into a dropdown as shown in the following +example: + +.. literalinclude:: _Examples/_GroupingToolbarItems.yaml + :language: yaml + :caption: EXT:my_sitepackage/Configuration/RTE/MyPreset.yaml + + +How do I allow a specific tag? +============================== + +Allowed content in CKEditor 5 is to be configured via the General HTML Support +plugin option :yaml:`config.htmlSupport`. + +.. literalinclude:: _Examples/_AllowSpecificTag.yaml + :language: yaml + :caption: EXT:my_sitepackage/Configuration/RTE/MyPreset.yaml + +.. note:: + :yaml:`config.htmlSupport` only applies to elements that are "known" to + CKEditor 5. Tags like :html:`` or custom elements like + :html:`` are not configurable this way as + :yaml:`htmlSupport.allow` can only handle + elements that are defined in the `CKEditor 5 schema`_. + +.. _CKEditor 5 schema: https://ckeditor.com/docs/ckeditor5/latest/features/html/general-html-support.html#enabling-custom-elements + + +.. _config-example-fontplugin: + +How do I configure the font plugin? +=================================== + +.. versionadded:: 12.4.12 + +In order to use the font plugin, the RTE configuration needs to be adapted: + +.. literalinclude:: _Examples/_FontPlugin.yaml + :language: yaml + :caption: EXT:my_sitepackage/Configuration/RTE/MyPreset.yaml + +More information can be found in the +`official documentation of CKEditor `__. + +How do I enable the fullscreen plugin? +====================================== + +.. versionadded:: 13.4.16 + +In order to use the fullscreen plugin, the RTE configuration needs to be adapted: + +.. literalinclude:: _Examples/_FullscreenPlugin.yaml + :language: yaml + :caption: EXT:my_sitepackage/Configuration/RTE/MyPreset.yaml + +More information can be found in the +`official documentation of CKEditor `__. + +.. _config-example-customplugin: + +How do I configure the Link Browser? +==================================== + +The TYPO3 Link Browser can be utilized in both the RTE and for FormEngine TCA fields. The latter +is configured through `TCA` settings, and the RTE editor itself is configured via the central +YAML file. + +There are several configuration options available. Please see :ref:`config-linkbrowser` for +the detailed reference, and :t3src:`rte_ckeditor/Configuration/RTE/Editor/LinkBrowser.yaml` +for an example configuration. + +How do I create a custom plugin? +================================ + +With CKEditor 5 the plugin architecture has changed and CKEditor 4 plugins +are not compatible with CKEditor 5. It is advised to read the +`CKEditor 4 to 5 migration `__ +to understand the conceptual changes, also related to plugins. + +Writing a custom plugin for CKEditor 5 can be done in TypeScript or JavaScript, +using the `CKEditor 5 plugin system `__. + +In this example, we integrate a simple timestamp plugin to CKEditor 5. +Make sure to replace `` with your extension key. + +.. rst-class:: bignums + +1. Create the plugin file + + Add the following ES6 JavaScript code: + + .. code-block:: javascript + :caption: EXT:/Resources/Public/JavaScript/Ckeditor/timestamp-plugin.js + + import { Plugin } from '@ckeditor/ckeditor5-core'; + import { ButtonView } from '@ckeditor/ckeditor5-ui'; + + export class Timestamp extends Plugin { + static pluginName = 'Timestamp'; + + init() { + const editor = this.editor; + + // The button must be registered among the UI components of the editor + // to be displayed in the toolbar. + editor.ui.componentFactory.add(Timestamp.pluginName, () => { + // The button will be an instance of ButtonView. + const button = new ButtonView(); + + button.set({ + label: 'Timestamp', + withText: true + }); + + // Execute a callback function when the button is clicked + button.on('execute', () => { + const now = new Date(); + + // Change the model using the model writer + editor.model.change(writer => { + + // Insert the text at the user's current position + editor.model.insertContent(writer.createText(now.toString())); + }); + }); + + return button; + }); + } + } + +2. Register the ES6 JavaScript + + .. literalinclude:: _Examples/_timestamp-plugin_JavaScriptModules.php + :language: php + :caption: EXT:/Configuration/JavaScriptModules.php + +3. Include the plugin in the CKEditor configuration + + .. literalinclude:: _Examples/_timestamp-plugin.yaml + :language: yaml + :caption: EXT:/Configuration/RTE/MyPreset.yaml + :emphasize-lines: 4,14 + :linenos: + + The :yaml:`importModules` item in line 4 imports the previously registered ES6 + module. The :yaml:`timestamp` item in line 14 adds the plugin to the toolbar. + +4. Use the plugin + + .. figure:: images/timestamp-plugin.png + :class: with-shadow + :alt: The custom timestamp plugin in the editor + + The custom timestamp plugin in the editor + +.. ------------------------------------- +.. todo: additional questions + What are stylesets? + Some configuration can be done with Page TSconfig, some with TCA and some with YAML and some with either 2 or more of these. Why and what should be configured where? + How can I configure classes to anchor tags? + What is the contents.css? + How can I set specific classes for anchors? + How can I extend custom tags? + How can I add images? + How can I configure tables? + How can I add more attributes to anchor tags? + How can I allow / deny specific tags? + How to add custom styles for ul tags? diff --git a/Documentation/Configuration/Index.rst b/Documentation/Configuration/Index.rst new file mode 100644 index 0000000..8fd630c --- /dev/null +++ b/Documentation/Configuration/Index.rst @@ -0,0 +1,22 @@ +.. include:: /Includes.rst.txt + + +.. _configuration: + +============= +Configuration +============= + +You can use the shipped configuration and everything will work as preconfigured +(using the "default" preset). + + +.. toctree:: + + QuickStart + ConfigureTypo3 + Concepts + BestPractices + Examples + Reference + diff --git a/Documentation/Configuration/QuickStart.rst b/Documentation/Configuration/QuickStart.rst new file mode 100644 index 0000000..d616075 --- /dev/null +++ b/Documentation/Configuration/QuickStart.rst @@ -0,0 +1,62 @@ +.. include:: /Includes.rst.txt + + +.. _config-quickstart: + +======================== +Configuration Quickstart +======================== + +Here we explain, how to modify the existing configuration in a few simple steps. + +View Existing Configuration +=========================== + +To familiarize yourself with the configuration, look at the existing configuration +in your TYPO3 website: + +To view the existing RTE presets in the "Global Configuration", go to +:guilabel:`System > Configuration` in the backend, choose +:guilabel:`$GLOBALS['TYPO3_CONF_VARS'] (Global Configuration)` and select +:guilabel:`RTE`: + +.. figure:: images/global-configuration-rte.png + :class: with-shadow + + Global Configuration: RTE > Presets + +By default, TYPO3 is shipped with three configuration presets: + +* default +* full +* minimal + +Minimal Example +=============== + +Here is a very minimal example of changing the default configuration. All +configuration is done in a custom sitepackage extension, see also +:ref:`best-practice-sitepackage`. + +Override the configuration preset "default" by adding this in :file:`/ext_localconf.php` +(replace `my_extension` with your extension key): + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['default'] = 'EXT:my_extension/Configuration/RTE/Default.yaml'; + +Add the file :file:`Configuration/RTE/Default.yaml` to your extension, use the file +:t3src:`rte_ckeditor/Configuration/RTE/Full.yaml` as example. + +We explain the example :file:`Minimal.yaml` from the Core: + +.. literalinclude:: _Quickstart/_Minimal.yaml + :language: yaml + :caption: EXT:rte_ckeditor/Configuration/RTE/Minimal.yaml + :linenos: + +line #2 + Imports existing files to make basic parts reusable and improve structure of configuration + +line #9 toolbar + See `toolbar `__ diff --git a/Documentation/Configuration/Reference.rst b/Documentation/Configuration/Reference.rst new file mode 100644 index 0000000..d677c4f --- /dev/null +++ b/Documentation/Configuration/Reference.rst @@ -0,0 +1,463 @@ +.. include:: /Includes.rst.txt + + +.. _config-ref: + +======================= +Configuration Reference +======================= + +.. _config-ref-yaml: + +YAML Configuration Reference +============================ + +When configuring the CKEditor using YAML, these are the property +names that are currently used: + +.. contents:: + :local: + :depth: 1 + +processing +---------- + +Configuring transformations kicks in the RteHtmlParser API of TYPO3, to +only allow certain HTML tags and attributes when saving the database or +leaving the database to the RTE. However, defining transformations towards +RTE is not really necessary anymore. Defining more strict processing options +when storing content in the database also needs to be ensured that CKEditor +allows this functionality too. + +This configuration option was previously built within `RTE.proc` and can +still be overridden via Page TSconfig. Everything defined via “processing” +is available in RTE.proc and triggers RteHtmlParser options. + +editor +------ + +Editor contains all RTE-specific options. All CKEditor-specific options, which one +could imagine are available under “config” property and handed over to CKEditor’s +instance-specific config array. + +All other sub-properties are usually handled via TYPO3 and then injected in the +CKEditor instance at runtime. This is useful for registering extra plugins, like +the TYPO3 core does with a custom :file:`typo3-link.js` plugin, or adding +third-party plugins like handling images. + +editor.config +~~~~~~~~~~~~~ + +.. option:: editor.config + + Configuration options For a list of all options see + https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editorconfig-EditorConfig.html + + .. note:: + Some configuration options from the official CKEditor 5 documentation + do not apply to TYPO3, since they are related to specific plugins + (for example: CKBox, CloudServices) which are not bundled in TYPO3's + CKEditor build. + +.. option:: editor.config.language + + defines the editor’s UI language, and is dynamically calculated (if not set otherwise) by + the backend users’ preference. + +.. option:: editor.config.contentsLanguage + + defines the language of the data, which is fetched from the + sys_language information, but can be overridden by this option as well. + For referencing files, TYPO3's internal "EXT:" syntax can be used, for + using language labels, TYPO3's "LLL:" language functionality can be used. + +.. option:: editor.config.contentsCss + + defines the location of one or multiple CSS file(s) of the editor, containing the style + definitions that will be applied to the backend editor RTE element. + + Example with single file: + + .. code-block:: yaml + :caption: MyCKPreset.yaml + + editor.config.contentsCss: + - "EXT:rte_ckeditor/Resources/Public/Css/contents.css" + + This is the default, as defined in :t3src:`rte_ckeditor/Configuration/RTE/Editor/Base.yaml`. + + Example with multiple files: + + .. code-block:: yaml + :caption: MyCKPreset.yaml + + editor.config.contentsCss: + - "EXT:rte_ckeditor/Resources/Public/Css/contents.css" + - "EXT:my_sitepackage/Resources/Public/Css/contents.css?v=2" + + Since the CKEditor element is rendered within the page content of the TYPO3 backend + (and not in an iframe or web-component), all CSS declarations in that file + must refer to an actual element hierarchy ending like + :css:`#data_tt_content__2687__bodytext_ckeditor5 .ck-content`. To achieve this, + TYPO3 automatically parses the contents of the CSS file with a process called + "auto-prefixing" (via JavaScript, client-side) and converts all references to + that "virtual" root hierarchy. + + A CSS declaration like :css:`:root { background-color: green }` gets turned into + :css:`#data_tt_content__2687__bodytext_ckeditor5 .ck-content { background-color: green; }`. + + You can use a :css:`:root { ... }` declaration, for example to reset + relative/absolute sizes to ensure the CKEditor area being compatible to your + usual frontend CSS. Also using `body {...}` is viable. + + .. note:: + Referenced CSS stylesheets need to + be downloadable via :js:`fetch()` in order for the JavaScript-based + prefixing to work. + + .. note:: + Also note that the generated CSS file is cached by your browser. If you change + the contents of your CSS file, be sure to either reload the browser cache, + or use a directive like + :yaml:`editor.config.contentsCss: "EXT:my_sitepackage/Resources/Public/Css/contents.css?v=2"` + where you change the `?v=` URI string after any file modification to enforce + requesting an updated version of the file. + +.. option:: editor.config.heading + + Defines headings available in the heading dropdown. + + Example: + + .. code-block:: yaml + :caption: MyCKPreset.yaml + + heading: + options: + - { model: 'heading2', view: 'h2', title: 'Heading 2' } + - { model: 'heading3', view: 'h3', title: 'Heading 3' } + - { model: 'heading4', view: 'h4', title: 'Heading 4' } + + It is also possible to set a class for a heading by default + (for example, :html:`

`): + + .. code-block:: yaml + :caption: MyCKPreset.yaml + + heading: + options: + - { model: 'heading2', view: { name: 'h2', classes: 'h2' }, title: 'Heading 2' } + - { model: 'heading3', view: { name: 'h3', classes: 'h3' }, title: 'Heading 3' } + - { model: 'heading4', view: { name: 'h4', classes: 'h4' }, title: 'Heading 4' } + + To be able to reset a heading to a paragraph, add also the :yaml:`paragraph` + option: + + .. code-block:: yaml + :caption: MyCKPreset.yaml + :emphasize-lines: 3 + + heading: + options: + - { model: 'paragraph', title: 'Paragraph' } + - { model: 'heading2', view 'h2', title: 'Heading 2' } + # ... + + A title can also be localized with `LLL:EXT:...`. + +.. option:: editor.config.style + + Defines styles available in the style dropdown. + + Example: + + .. code-block:: yaml + :caption: MyCKPreset.yaml + + style: + definitions: + - { name: "Lead", element: "p", classes: ['lead'] } + - { name: "Multiple", element: "p", classes: ['first', 'second'] } + +.. option:: editor.config.importModules + + Imports custom CKEditor plugins. See :t3src:`rte_ckeditor/Configuration/RTE/Editor/Plugins.yaml` + or :ref:`How do I create a custom plugin? ` + for examples. + +.. _config-linkbrowser: +Link Browser specific options +----------------------------- + +There are more configuration options that can be defined in the YAML file of an RTE preset +related to the Link Browser, when managing hyperlinks inside the CKEditor. + +Note that the Link Browser can also be displayed based on FormEngine TCA definitions. These +use similar configuration, but from their TCA PHP configuration, and unrelated to the YAML +definition. + +The additional example file :t3src:`rte_ckeditor/Configuration/RTE/Editor/LinkBrowser.yaml` +lists all of the following options as an example. + +These options are a bit fragmented, it is important to watch for the proper indentation as well +the proper option relation. + +.. important:: + Please note that these options are set at the topmost level, and **not** nested inside + the `editor` YAML structure. + +A short overview: + +* `allowedOptions` - allowed list of additional attribute boxes +* `allowedTypes` - list of allowed Link Types inside the RTE +* `classesAnchor` - list of default CSS and link target values per Link Type +* `buttons` - Additional sub-configuration array for specific dropdowns +* `buttons.link.options` - Global options for the Link Browser +* `buttons.link.relAttribute` - Configuration for the `rel` attribute block +* `buttons.link.queryParametersSelector` - Configuration for the `queryParameter` (URI arguments) attribute block +* `buttons.link.targetSelector` - Configuration for the `target` attribute block +* `buttons.link.properties.class.allowedClasses` - Allowed additional CSS classes in the `CSS` attribute block +* `buttons.link.[LinkType].properties.class.default` - Default CSS class per Link Type +* `classes` - Label definitions for CSS class names + +allowedOptions +~~~~~~~~~~~~~~ + +This string contains a comma separated list of additional attributes used in the Link Browser. +Available field lists can be found in :t3src:`backend/Classes/Controller/AbstractLinkBrowserController.php`, +method :php:`getLinkAttributeFieldDefinitions()`. + +Note that the attributes `target`, `class` and `rel` are displayed differently depending on +whether the Link Browser was opened for a TCA element, or a RTE element. See +:t3src:`rte_ckeditor/Classes/Controller/BrowseLinksController.php` in method +`getLinkAttributeFieldDefinitions()`. + +Valid attributes keys are: + +.. option:: target + + If set, an input box for link target (for example "_blank") is available. + +.. option:: title + + If set, entering the link title is available. + +.. option:: class + + If set, allowing to enter a CSS class name for the link is available. + This needs to match the CSS classes made available to the CKEDitor instance. + +.. option:: params + + If set, additional parameters are allowed to be set for a link. + +.. option:: rel + + If set, relations (:html:`rel` attribute) for links can be set. + +To set all of them, you can use: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + allowedOptions: 'target,title,class,params,rel' + +To remove all options you can use an empty string: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + allowedOptions: '' + +allowedTypes +~~~~~~~~~~~~ + +This string contains a comma-separated list of all allowed Link Types +for the Link Browser. These are currently: + +* `page` +* `url` +* `file` +* `folder` +* `email` +* `...` any custom Link Type + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + allowedTypes: 'page,url,file,folder,email,customType' + +To remove all types you can use an empty string: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + allowedTypes: '' + +classesAnchor +~~~~~~~~~~~~~ + +This is a sub-array of default CSS classes and target attributes, per Link Type: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + classesAnchor: + - { class: "customPageCssClass", type: "page", target: "" } + - { class: "customUrlCssClass", type: "url", target: "_blank" } + - { class: "customFileCssClass", type: "file", target: "_parent" } + - { class: "customFolderCssClass", type: "folder" } + - { class: "customTelephoneCssClass", type: "telephone" } + - { class: "customEmailCssClass", type: "email" } + +Note that the available CSS class here must also be part of the +`buttons.link.properties.class.allowedClasses` definition. + +buttons.link +~~~~~~~~~~~~ + +This structure defines both global options as well as Link Type-specific +options: + +buttons.link.options.removeItems +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Can be set to exclude certain Link Types: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + buttons: + link: + options: + removeItems: 'telephone' + +buttons.link.relAttribute.enabled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the `allowedOptions` string list contains `rel` for setting relation +attributes, this option must also be enabled: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + buttons: + link: + relAttribute: + enabled: true + +buttons.link.queryParametersSelector.enabled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the `allowedOptions` string list contains `params` for setting URI argument +attributes, this option must also be enabled: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + buttons: + link: + queryParametersSelector: + enabled: true + + +buttons.link.targetSelector.disabled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the `allowedOptions` string list contains `target`, a dropdown is displayed by +default. If you want to hide it, you must set this option to `true`: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + buttons: + link: + targetSelector: + disabled: true + +buttons.link.properties.class.required +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A CSS class selection can be forced, so that it may not be empty: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + buttons: + link: + properties: + class: + required: true + +buttons.link.properties.class.allowedClasses +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This is the most vital CSS class selection list, based on a comma-separated +string naming all CSS classes that are allowed. Default CSS classes per Link Type +can only be selected, if they are part of this list. + +The names of the CSS classes can be adjusted via the `classes` top-level configuration +hierarchy (see below) + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + buttons: + link: + properties: + class: + allowedClasses: 'globalCss1,globalCss1,CustomPageCssClass' + +buttons.link.[linkType].properties.class.default +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For each Link Type, a default CSS class can be defined, using the name of the +Link Type as a key: + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + buttons: + link: + telephone: + class: + default: "customTelephoneCssClass" + email: + class: + default: "customEmailCssClass" + +Note that the CSS class listed here must also be contained in +`buttons.link.properties.class.allowedClasses`. + +classes.[CssClassName] +~~~~~~~~~~~~~~~~~~~~~~ + +The list of CSS classes defined in `buttons.link.properties.class.allowedClasses` +can set a custom label as well as a styling the select option. Note that styling +select options does not work in every browser, and is not suggested to use. + +The name of the structure key must match the CSS class name, with a sub-structure +defining `name` (the actual label) and `value` (the possible CSS styling of the option +inside the dropdown): + +.. code-block:: yaml + :caption: MyCKPreset.yaml + + classes: + globalCss1: + name: "A Label for globalCss1" + value: "color: red" + customEmailCssClass: + name: "An email-specific class for VIPs" + +.. _config-ref-tsconfig: + +Page TSconfig +============= + +We recommend you to put all configurations for the preset in the +:ref:`YAML ` configuration. However, it is still possible to +override these settings through the page TSconfig. + +You can find a list of configuration properties in the :ref:`Page TSconfig +reference, chapter RTE `. diff --git a/Documentation/Configuration/_Examples/_AllowSpecificTag.yaml b/Documentation/Configuration/_Examples/_AllowSpecificTag.yaml new file mode 100644 index 0000000..79ad439 --- /dev/null +++ b/Documentation/Configuration/_Examples/_AllowSpecificTag.yaml @@ -0,0 +1,14 @@ +# Allow the