[BUGFIX] Re-Init CKEditor after re-sorting of inline records
[Packages/TYPO3.CMS.git] / typo3 / sysext / rte_ckeditor / Classes / Form / Element / RichTextElement.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\RteCKEditor\Form\Element;
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18 use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;
19 use TYPO3\CMS\Backend\Routing\UriBuilder;
20 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
21 use TYPO3\CMS\Core\Localization\Locales;
22 use TYPO3\CMS\Core\Utility\GeneralUtility;
23 use TYPO3\CMS\Core\Utility\PathUtility;
24
25 /**
26 * Render rich text editor in FormEngine
27 */
28 class RichTextElement extends AbstractFormElement
29 {
30 /**
31 * Default field wizards enabled for this element.
32 *
33 * @var array
34 */
35 protected $defaultFieldWizard = [
36 'localizationStateSelector' => [
37 'renderType' => 'localizationStateSelector',
38 ],
39 'otherLanguageContent' => [
40 'renderType' => 'otherLanguageContent',
41 'after' => [
42 'localizationStateSelector'
43 ],
44 ],
45 'defaultLanguageDifferences' => [
46 'renderType' => 'defaultLanguageDifferences',
47 'after' => [
48 'otherLanguageContent',
49 ],
50 ],
51 ];
52
53 /**
54 * This property contains configuration related to the RTE
55 * But only the .editor configuration part
56 *
57 * @var array
58 */
59 protected $rteConfiguration = [];
60
61 /**
62 * Renders the ckeditor element
63 *
64 * @return array
65 * @throws \InvalidArgumentException
66 */
67 public function render(): array
68 {
69 $resultArray = $this->initializeResultArray();
70 $parameterArray = $this->data['parameterArray'];
71 $config = $parameterArray['fieldConf']['config'];
72
73 $fieldId = $this->sanitizeFieldId($parameterArray['itemFormElName']);
74 $itemFormElementName = $this->data['parameterArray']['itemFormElName'];
75
76 $value = $this->data['parameterArray']['itemFormElValue'] ?? '';
77
78 $fieldInformationResult = $this->renderFieldInformation();
79 $fieldInformationHtml = $fieldInformationResult['html'];
80 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
81
82 $fieldControlResult = $this->renderFieldControl();
83 $fieldControlHtml = $fieldControlResult['html'];
84 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false);
85
86 $fieldWizardResult = $this->renderFieldWizard();
87 $fieldWizardHtml = $fieldWizardResult['html'];
88 $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
89
90 $attributes = [
91 'style' => 'display:none',
92 'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config),
93 'id' => $fieldId,
94 'name' => htmlspecialchars($itemFormElementName),
95 ];
96
97 $html = [];
98 $html[] = '<div class="formengine-field-item t3js-formengine-field-item">';
99 $html[] = $fieldInformationHtml;
100 $html[] = '<div class="form-control-wrap">';
101 $html[] = '<div class="form-wizards-wrap">';
102 $html[] = '<div class="form-wizards-element">';
103 $html[] = '<textarea ' . GeneralUtility::implodeAttributes($attributes, true) . '>';
104 $html[] = htmlspecialchars($value);
105 $html[] = '</textarea>';
106 $html[] = '</div>';
107 $html[] = '<div class="form-wizards-items-aside">';
108 $html[] = '<div class="btn-group">';
109 $html[] = $fieldControlHtml;
110 $html[] = '</div>';
111 $html[] = '</div>';
112 $html[] = '<div class="form-wizards-items-bottom">';
113 $html[] = $fieldWizardHtml;
114 $html[] = '</div>';
115 $html[] = '</div>';
116 $html[] = '</div>';
117 $html[] = '</div>';
118
119 $resultArray['html'] = implode(LF, $html);
120
121 $this->rteConfiguration = $config['richtextConfiguration']['editor'];
122 $resultArray['requireJsModules'] = [];
123 $resultArray['requireJsModules'][] = [
124 'ckeditor' => $this->getCkEditorRequireJsModuleCode($fieldId)
125 ];
126
127 return $resultArray;
128 }
129
130 /**
131 * Determine the contents language iso code
132 *
133 * @return string
134 */
135 protected function getLanguageIsoCodeOfContent(): string
136 {
137 $currentLanguageUid = $this->data['databaseRow']['sys_language_uid'];
138 if (is_array($currentLanguageUid)) {
139 $currentLanguageUid = $currentLanguageUid[0];
140 }
141 $contentLanguageUid = (int)max($currentLanguageUid, 0);
142 if ($contentLanguageUid) {
143 $contentLanguage = $this->data['systemLanguageRows'][$currentLanguageUid]['iso'];
144 } else {
145 $contentLanguage = $this->rteConfiguration['config']['defaultContentLanguage'] ?? 'en_US';
146 $languageCodeParts = explode('_', $contentLanguage);
147 $contentLanguage = strtolower($languageCodeParts[0]) . ($languageCodeParts[1] ? '_' . strtoupper($languageCodeParts[1]) : '');
148 // Find the configured language in the list of localization locales
149 $locales = GeneralUtility::makeInstance(Locales::class);
150 // If not found, default to 'en'
151 if (!in_array($contentLanguage, $locales->getLocales(), true)) {
152 $contentLanguage = 'en';
153 }
154 }
155 return $contentLanguage;
156 }
157
158 /**
159 * Gets the JavaScript code for CKEditor module
160 * Compiles the configuration, and then adds plugins
161 *
162 * @param string $fieldId
163 * @return string
164 */
165 protected function getCkEditorRequireJsModuleCode(string $fieldId): string
166 {
167 $configuration = $this->prepareConfigurationForEditor();
168
169 $externalPlugins = '';
170 foreach ($this->getExtraPlugins() as $pluginName => $config) {
171 if (!empty($config['config']) && !empty($configuration[$pluginName])) {
172 $config['config'] = array_replace_recursive($config['config'], $configuration[$pluginName]);
173 }
174 $configuration[$pluginName] = $config['config'];
175 $configuration['extraPlugins'] .= ',' . $pluginName;
176
177 $externalPlugins .= 'CKEDITOR.plugins.addExternal(';
178 $externalPlugins .= GeneralUtility::quoteJSvalue($pluginName) . ',';
179 $externalPlugins .= GeneralUtility::quoteJSvalue($config['resource']) . ',';
180 $externalPlugins .= '\'\');';
181 }
182
183 return 'function(CKEDITOR) {
184 ' . $externalPlugins . '
185 $(function(){
186 CKEDITOR.replace("' . $fieldId . '", ' . json_encode($configuration) . ');
187 require([\'jquery\', \'TYPO3/CMS/Backend/FormEngine\'], function($, FormEngine) {
188 CKEDITOR.instances["' . $fieldId . '"].on(\'change\', function() {
189 CKEDITOR.instances["' . $fieldId . '"].updateElement();
190 FormEngine.Validation.validate();
191 FormEngine.Validation.markFieldAsChanged($(\'#' . $fieldId . '\'));
192 });
193 $(document).on(\'inline:sorting-changed\', function() {
194 CKEDITOR.instances["' . $fieldId . '"].destroy();
195 CKEDITOR.replace("' . $fieldId . '", ' . json_encode($configuration) . ');
196 });
197 $(document).on(\'flexform:sorting-changed\', function() {
198 CKEDITOR.instances["' . $fieldId . '"].destroy();
199 CKEDITOR.replace("' . $fieldId . '", ' . json_encode($configuration) . ');
200 });
201 });
202 });
203 }';
204 }
205
206 /**
207 * Get configuration of external/additional plugins
208 *
209 * @return array
210 */
211 protected function getExtraPlugins(): array
212 {
213 $urlParameters = [
214 'P' => [
215 'table' => $this->data['tableName'],
216 'uid' => $this->data['databaseRow']['uid'],
217 'fieldName' => $this->data['fieldName'],
218 'recordType' => $this->data['recordTypeValue'],
219 'pid' => $this->data['effectivePid'],
220 'richtextConfigurationName' => $this->data['parameterArray']['fieldConf']['config']['richtextConfigurationName']
221 ]
222 ];
223
224 $pluginConfiguration = [];
225 if (isset($this->rteConfiguration['externalPlugins']) && is_array($this->rteConfiguration['externalPlugins'])) {
226 $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
227 foreach ($this->rteConfiguration['externalPlugins'] as $pluginName => $configuration) {
228 $pluginConfiguration[$pluginName] = [
229 'resource' => $this->resolveUrlPath($configuration['resource'])
230 ];
231 unset($configuration['resource']);
232
233 if ($configuration['route']) {
234 $configuration['routeUrl'] = (string)$uriBuilder->buildUriFromRoute($configuration['route'], $urlParameters);
235 }
236
237 $pluginConfiguration[$pluginName]['config'] = $configuration;
238 }
239 }
240 return $pluginConfiguration;
241 }
242
243 /**
244 * Add configuration to replace LLL: references with the translated value
245 * @param array $configuration
246 *
247 * @return array
248 */
249 protected function replaceLanguageFileReferences(array $configuration): array
250 {
251 foreach ($configuration as $key => $value) {
252 if (is_array($value)) {
253 $configuration[$key] = $this->replaceLanguageFileReferences($value);
254 } elseif (is_string($value) && stripos($value, 'LLL:') === 0) {
255 $configuration[$key] = $this->getLanguageService()->sL($value);
256 }
257 }
258 return $configuration;
259 }
260
261 /**
262 * Add configuration to replace absolute EXT: paths with relative ones
263 * @param array $configuration
264 *
265 * @return array
266 */
267 protected function replaceAbsolutePathsToRelativeResourcesPath(array $configuration): array
268 {
269 foreach ($configuration as $key => $value) {
270 if (is_array($value)) {
271 $configuration[$key] = $this->replaceAbsolutePathsToRelativeResourcesPath($value);
272 } elseif (is_string($value) && stripos($value, 'EXT:') === 0) {
273 $configuration[$key] = $this->resolveUrlPath($value);
274 }
275 }
276 return $configuration;
277 }
278
279 /**
280 * Resolves an EXT: syntax file to an absolute web URL
281 *
282 * @param string $value
283 * @return string
284 */
285 protected function resolveUrlPath(string $value): string
286 {
287 $value = GeneralUtility::getFileAbsFileName($value);
288 return PathUtility::getAbsoluteWebPath($value);
289 }
290
291 /**
292 * Compiles the configuration set from the outside
293 * to have it easily injected into the CKEditor.
294 *
295 * @return array the configuration
296 */
297 protected function prepareConfigurationForEditor(): array
298 {
299 // Ensure custom config is empty so nothing additional is loaded
300 // Of course this can be overridden by the editor configuration below
301 $configuration = [
302 'customConfig' => '',
303 ];
304
305 if (is_array($this->rteConfiguration['config'])) {
306 $configuration = array_replace_recursive($configuration, $this->rteConfiguration['config']);
307 }
308 // Set the UI language of the editor if not hard-coded by the existing configuration
309 if (empty($configuration['language'])) {
310 $configuration['language'] = $this->getBackendUser()->uc['lang'] ?: ($this->getBackendUser()->user['lang'] ?: 'en');
311 }
312 $configuration['contentsLanguage'] = $this->getLanguageIsoCodeOfContent();
313
314 // Replace all label references
315 $configuration = $this->replaceLanguageFileReferences($configuration);
316 // Replace all paths
317 $configuration = $this->replaceAbsolutePathsToRelativeResourcesPath($configuration);
318
319 // there are some places where we define an array, but it needs to be a list in order to work
320 if (is_array($configuration['extraPlugins'])) {
321 $configuration['extraPlugins'] = implode(',', $configuration['extraPlugins']);
322 }
323 if (is_array($configuration['removePlugins'])) {
324 $configuration['removePlugins'] = implode(',', $configuration['removePlugins']);
325 }
326 if (is_array($configuration['removeButtons'])) {
327 $configuration['removeButtons'] = implode(',', $configuration['removeButtons']);
328 }
329
330 return $configuration;
331 }
332
333 /**
334 * @param string $itemFormElementName
335 * @return string
336 */
337 protected function sanitizeFieldId(string $itemFormElementName): string
338 {
339 $fieldId = preg_replace('/[^a-zA-Z0-9_:.-]/', '_', $itemFormElementName);
340 return htmlspecialchars(preg_replace('/^[^a-zA-Z]/', 'x', $fieldId));
341 }
342
343 /**
344 * @return BackendUserAuthentication
345 */
346 protected function getBackendUser()
347 {
348 return $GLOBALS['BE_USER'];
349 }
350 }