1<?php 2 3namespace Drupal\ckeditor\Plugin\CKEditorPlugin; 4 5use Drupal\ckeditor\CKEditorPluginBase; 6use Drupal\ckeditor\CKEditorPluginContextualInterface; 7use Drupal\ckeditor\CKEditorPluginManager; 8use Drupal\Component\Utility\Html; 9use Drupal\Core\Cache\Cache; 10use Drupal\Core\Cache\CacheBackendInterface; 11use Drupal\Core\Plugin\ContainerFactoryPluginInterface; 12use Drupal\editor\Entity\Editor; 13use Drupal\filter\Plugin\FilterInterface; 14use Symfony\Component\DependencyInjection\ContainerInterface; 15 16/** 17 * Defines the "internal" plugin (i.e. core plugins part of our CKEditor build). 18 * 19 * @CKEditorPlugin( 20 * id = "internal", 21 * label = @Translation("CKEditor core") 22 * ) 23 */ 24class Internal extends CKEditorPluginBase implements ContainerFactoryPluginInterface, CKEditorPluginContextualInterface { 25 26 /** 27 * The cache backend. 28 * 29 * @var \Drupal\Core\Cache\CacheBackendInterface 30 */ 31 protected $cache; 32 33 /** 34 * Constructs a \Drupal\ckeditor\Plugin\CKEditorPlugin\Internal object. 35 * 36 * @param array $configuration 37 * A configuration array containing information about the plugin instance. 38 * @param string $plugin_id 39 * The plugin_id for the plugin instance. 40 * @param mixed $plugin_definition 41 * The plugin implementation definition. 42 * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend 43 * The cache backend. 44 */ 45 public function __construct(array $configuration, $plugin_id, $plugin_definition, CacheBackendInterface $cache_backend) { 46 $this->cache = $cache_backend; 47 parent::__construct($configuration, $plugin_id, $plugin_definition); 48 } 49 50 /** 51 * Creates an instance of the plugin. 52 * 53 * @param \Symfony\Component\DependencyInjection\ContainerInterface $container 54 * The container to pull out services used in the plugin. 55 * @param array $configuration 56 * A configuration array containing information about the plugin instance. 57 * @param string $plugin_id 58 * The plugin ID for the plugin instance. 59 * @param mixed $plugin_definition 60 * The plugin implementation definition. 61 * 62 * @return static 63 * Returns an instance of this plugin. 64 */ 65 public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { 66 return new static( 67 $configuration, 68 $plugin_id, 69 $plugin_definition, 70 $container->get('cache.default') 71 ); 72 } 73 74 /** 75 * {@inheritdoc} 76 */ 77 public function isInternal() { 78 return TRUE; 79 } 80 81 /** 82 * {@inheritdoc} 83 */ 84 public function isEnabled(Editor $editor) { 85 // This plugin represents the core CKEditor plugins. They're always enabled: 86 // its configuration is always necessary. 87 return TRUE; 88 } 89 90 /** 91 * {@inheritdoc} 92 */ 93 public function getFile() { 94 // This plugin is already part of Drupal core's CKEditor build. 95 return FALSE; 96 } 97 98 /** 99 * {@inheritdoc} 100 */ 101 public function getConfig(Editor $editor) { 102 // Reasonable defaults that provide expected basic behavior. 103 $config = [ 104 // Don't load CKEditor's config.js file. 105 'customConfig' => '', 106 'pasteFromWordPromptCleanup' => TRUE, 107 'resize_dir' => 'vertical', 108 'justifyClasses' => ['text-align-left', 'text-align-center', 'text-align-right', 'text-align-justify'], 109 'entities' => FALSE, 110 'disableNativeSpellChecker' => FALSE, 111 ]; 112 113 // Add the allowedContent setting, which ensures CKEditor only allows tags 114 // and attributes that are allowed by the text format for this text editor. 115 list($config['allowedContent'], $config['disallowedContent']) = $this->generateACFSettings($editor); 116 117 // Add the format_tags setting, if its button is enabled. 118 $toolbar_buttons = CKEditorPluginManager::getEnabledButtons($editor); 119 if (in_array('Format', $toolbar_buttons)) { 120 $config['format_tags'] = $this->generateFormatTagsSetting($editor); 121 } 122 123 return $config; 124 } 125 126 /** 127 * {@inheritdoc} 128 */ 129 public function getButtons() { 130 $button = function ($name, $direction = 'ltr') { 131 // In the markup below, we mostly use the name (which may include spaces), 132 // but in one spot we use it as a CSS class, so strip spaces. 133 // Note: this uses str_replace() instead of Html::cleanCssIdentifier() 134 // because we must provide these class names exactly how CKEditor expects 135 // them in its library, which cleanCssIdentifier() does not do. 136 $class_name = str_replace(' ', '', $name); 137 return [ 138 '#type' => 'inline_template', 139 '#template' => '<a href="#" class="cke-icon-only cke_{{ direction }}" role="button" title="{{ name }}" aria-label="{{ name }}"><span class="cke_button_icon cke_button__{{ classname }}_icon">{{ name }}</span></a>', 140 '#context' => [ 141 'direction' => $direction, 142 'name' => $name, 143 'classname' => $class_name, 144 ], 145 ]; 146 }; 147 148 return [ 149 // "basicstyles" plugin. 150 'Bold' => [ 151 'label' => $this->t('Bold'), 152 'image_alternative' => $button('bold'), 153 'image_alternative_rtl' => $button('bold', 'rtl'), 154 ], 155 'Italic' => [ 156 'label' => $this->t('Italic'), 157 'image_alternative' => $button('italic'), 158 'image_alternative_rtl' => $button('italic', 'rtl'), 159 ], 160 'Underline' => [ 161 'label' => $this->t('Underline'), 162 'image_alternative' => $button('underline'), 163 'image_alternative_rtl' => $button('underline', 'rtl'), 164 ], 165 'Strike' => [ 166 'label' => $this->t('Strike-through'), 167 'image_alternative' => $button('strike'), 168 'image_alternative_rtl' => $button('strike', 'rtl'), 169 ], 170 'Superscript' => [ 171 'label' => $this->t('Superscript'), 172 'image_alternative' => $button('super script'), 173 'image_alternative_rtl' => $button('super script', 'rtl'), 174 ], 175 'Subscript' => [ 176 'label' => $this->t('Subscript'), 177 'image_alternative' => $button('sub script'), 178 'image_alternative_rtl' => $button('sub script', 'rtl'), 179 ], 180 // "removeformat" plugin. 181 'RemoveFormat' => [ 182 'label' => $this->t('Remove format'), 183 'image_alternative' => $button('remove format'), 184 'image_alternative_rtl' => $button('remove format', 'rtl'), 185 ], 186 // "justify" plugin. 187 'JustifyLeft' => [ 188 'label' => $this->t('Align left'), 189 'image_alternative' => $button('justify left'), 190 'image_alternative_rtl' => $button('justify left', 'rtl'), 191 ], 192 'JustifyCenter' => [ 193 'label' => $this->t('Align center'), 194 'image_alternative' => $button('justify center'), 195 'image_alternative_rtl' => $button('justify center', 'rtl'), 196 ], 197 'JustifyRight' => [ 198 'label' => $this->t('Align right'), 199 'image_alternative' => $button('justify right'), 200 'image_alternative_rtl' => $button('justify right', 'rtl'), 201 ], 202 'JustifyBlock' => [ 203 'label' => $this->t('Justify'), 204 'image_alternative' => $button('justify block'), 205 'image_alternative_rtl' => $button('justify block', 'rtl'), 206 ], 207 // "list" plugin. 208 'BulletedList' => [ 209 'label' => $this->t('Bullet list'), 210 'image_alternative' => $button('bulleted list'), 211 'image_alternative_rtl' => $button('bulleted list', 'rtl'), 212 ], 213 'NumberedList' => [ 214 'label' => $this->t('Numbered list'), 215 'image_alternative' => $button('numbered list'), 216 'image_alternative_rtl' => $button('numbered list', 'rtl'), 217 ], 218 // "indent" plugin. 219 'Outdent' => [ 220 'label' => $this->t('Outdent'), 221 'image_alternative' => $button('outdent'), 222 'image_alternative_rtl' => $button('outdent', 'rtl'), 223 ], 224 'Indent' => [ 225 'label' => $this->t('Indent'), 226 'image_alternative' => $button('indent'), 227 'image_alternative_rtl' => $button('indent', 'rtl'), 228 ], 229 // "undo" plugin. 230 'Undo' => [ 231 'label' => $this->t('Undo'), 232 'image_alternative' => $button('undo'), 233 'image_alternative_rtl' => $button('undo', 'rtl'), 234 ], 235 'Redo' => [ 236 'label' => $this->t('Redo'), 237 'image_alternative' => $button('redo'), 238 'image_alternative_rtl' => $button('redo', 'rtl'), 239 ], 240 // "blockquote" plugin. 241 'Blockquote' => [ 242 'label' => $this->t('Blockquote'), 243 'image_alternative' => $button('blockquote'), 244 'image_alternative_rtl' => $button('blockquote', 'rtl'), 245 ], 246 // "horizontalrule" plugin 247 'HorizontalRule' => [ 248 'label' => $this->t('Horizontal rule'), 249 'image_alternative' => $button('horizontal rule'), 250 'image_alternative_rtl' => $button('horizontal rule', 'rtl'), 251 ], 252 // "clipboard" plugin. 253 'Cut' => [ 254 'label' => $this->t('Cut'), 255 'image_alternative' => $button('cut'), 256 'image_alternative_rtl' => $button('cut', 'rtl'), 257 ], 258 'Copy' => [ 259 'label' => $this->t('Copy'), 260 'image_alternative' => $button('copy'), 261 'image_alternative_rtl' => $button('copy', 'rtl'), 262 ], 263 'Paste' => [ 264 'label' => $this->t('Paste'), 265 'image_alternative' => $button('paste'), 266 'image_alternative_rtl' => $button('paste', 'rtl'), 267 ], 268 // "pastetext" plugin. 269 'PasteText' => [ 270 'label' => $this->t('Paste Text'), 271 'image_alternative' => $button('paste text'), 272 'image_alternative_rtl' => $button('paste text', 'rtl'), 273 ], 274 // "pastefromword" plugin. 275 'PasteFromWord' => [ 276 'label' => $this->t('Paste from Word'), 277 'image_alternative' => $button('paste from word'), 278 'image_alternative_rtl' => $button('paste from word', 'rtl'), 279 ], 280 // "specialchar" plugin. 281 'SpecialChar' => [ 282 'label' => $this->t('Character map'), 283 'image_alternative' => $button('special char'), 284 'image_alternative_rtl' => $button('special char', 'rtl'), 285 ], 286 'Format' => [ 287 'label' => $this->t('HTML block format'), 288 'image_alternative' => [ 289 '#type' => 'inline_template', 290 '#template' => '<a href="#" role="button" aria-label="{{ format_text }}"><span class="ckeditor-button-dropdown">{{ format_text }}<span class="ckeditor-button-arrow"></span></span></a>', 291 '#context' => [ 292 'format_text' => $this->t('Format'), 293 ], 294 ], 295 ], 296 // "table" plugin. 297 'Table' => [ 298 'label' => $this->t('Table'), 299 'image_alternative' => $button('table'), 300 'image_alternative_rtl' => $button('table', 'rtl'), 301 ], 302 // "showblocks" plugin. 303 'ShowBlocks' => [ 304 'label' => $this->t('Show blocks'), 305 'image_alternative' => $button('show blocks'), 306 'image_alternative_rtl' => $button('show blocks', 'rtl'), 307 ], 308 // "sourcearea" plugin. 309 'Source' => [ 310 'label' => $this->t('Source code'), 311 'image_alternative' => $button('source'), 312 'image_alternative_rtl' => $button('source', 'rtl'), 313 ], 314 // "maximize" plugin. 315 'Maximize' => [ 316 'label' => $this->t('Maximize'), 317 'image_alternative' => $button('maximize'), 318 'image_alternative_rtl' => $button('maximize', 'rtl'), 319 ], 320 // No plugin, separator "button" for toolbar builder UI use only. 321 '-' => [ 322 'label' => $this->t('Separator'), 323 'image_alternative' => [ 324 '#type' => 'inline_template', 325 '#template' => '<a href="#" role="button" aria-label="{{ button_separator_text }}" class="ckeditor-separator"></a>', 326 '#context' => [ 327 'button_separator_text' => $this->t('Button separator'), 328 ], 329 ], 330 'attributes' => [ 331 'class' => ['ckeditor-button-separator'], 332 'data-drupal-ckeditor-type' => 'separator', 333 ], 334 'multiple' => TRUE, 335 ], 336 ]; 337 } 338 339 /** 340 * Builds the "format_tags" configuration part of the CKEditor JS settings. 341 * 342 * @see getConfig() 343 * 344 * @param \Drupal\editor\Entity\Editor $editor 345 * A configured text editor object. 346 * 347 * @return array 348 * An array containing the "format_tags" configuration. 349 */ 350 protected function generateFormatTagsSetting(Editor $editor) { 351 // When no text format is associated yet, assume no tag is allowed. 352 // @see \Drupal\editor\EditorInterface::hasAssociatedFilterFormat() 353 if (!$editor->hasAssociatedFilterFormat()) { 354 return []; 355 } 356 357 $format = $editor->getFilterFormat(); 358 $cid = 'ckeditor_internal_format_tags:' . $format->id(); 359 360 if ($cached = $this->cache->get($cid)) { 361 $format_tags = $cached->data; 362 } 363 else { 364 // The <p> tag is always allowed — HTML without <p> tags is nonsensical. 365 $format_tags = ['p']; 366 367 // Given the list of possible format tags, automatically determine whether 368 // the current text format allows this tag, and thus whether it should show 369 // up in the "Format" dropdown. 370 $possible_format_tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre']; 371 foreach ($possible_format_tags as $tag) { 372 $input = '<' . $tag . '>TEST</' . $tag . '>'; 373 $output = trim(check_markup($input, $editor->id())); 374 if (Html::load($output)->getElementsByTagName($tag)->length !== 0) { 375 $format_tags[] = $tag; 376 } 377 } 378 $format_tags = implode(';', $format_tags); 379 380 // Cache the "format_tags" configuration. This cache item is infinitely 381 // valid; it only changes whenever the text format is changed, hence it's 382 // tagged with the text format's cache tag. 383 $this->cache->set($cid, $format_tags, Cache::PERMANENT, $format->getCacheTags()); 384 } 385 386 return $format_tags; 387 } 388 389 /** 390 * Builds the ACF part of the CKEditor JS settings. 391 * 392 * This ensures that CKEditor obeys the HTML restrictions defined by Drupal's 393 * filter system, by enabling CKEditor's Advanced Content Filter (ACF) 394 * functionality: http://ckeditor.com/blog/CKEditor-4.1-RC-Released. 395 * 396 * @see getConfig() 397 * 398 * @param \Drupal\editor\Entity\Editor $editor 399 * A configured text editor object. 400 * 401 * @return array 402 * An array with two values: 403 * - the first value is the "allowedContent" setting: a well-formatted array 404 * or TRUE. The latter indicates that anything is allowed. 405 * - the second value is the "disallowedContent" setting: a well-formatted 406 * array or FALSE. The latter indicates that nothing is disallowed. 407 */ 408 protected function generateACFSettings(Editor $editor) { 409 // When no text format is associated yet, assume nothing is disallowed, so 410 // set allowedContent to true. 411 if (!$editor->hasAssociatedFilterFormat()) { 412 return TRUE; 413 } 414 415 $format = $editor->getFilterFormat(); 416 $filter_types = $format->getFilterTypes(); 417 418 // When nothing is disallowed, set allowedContent to true. 419 if (!in_array(FilterInterface::TYPE_HTML_RESTRICTOR, $filter_types)) { 420 return [TRUE, FALSE]; 421 } 422 // Generate setting that accurately reflects allowed tags and attributes. 423 else { 424 $get_attribute_values = function ($attribute_values, $allowed_values) { 425 $values = array_keys(array_filter($attribute_values, function ($value) use ($allowed_values) { 426 if ($allowed_values) { 427 return $value !== FALSE; 428 } 429 else { 430 return $value === FALSE; 431 } 432 })); 433 if (count($values)) { 434 return implode(',', $values); 435 } 436 else { 437 return NULL; 438 } 439 }; 440 441 $html_restrictions = $format->getHtmlRestrictions(); 442 // When all HTML is allowed, also set allowedContent to true and 443 // disallowedContent to false. 444 if ($html_restrictions === FALSE) { 445 return [TRUE, FALSE]; 446 } 447 $allowed = []; 448 $disallowed = []; 449 if (isset($html_restrictions['forbidden_tags'])) { 450 foreach ($html_restrictions['forbidden_tags'] as $tag) { 451 $disallowed[$tag] = TRUE; 452 } 453 } 454 foreach ($html_restrictions['allowed'] as $tag => $attributes) { 455 // Tell CKEditor the tag is allowed, but no attributes. 456 if ($attributes === FALSE) { 457 $allowed[$tag] = [ 458 'attributes' => FALSE, 459 'styles' => FALSE, 460 'classes' => FALSE, 461 ]; 462 } 463 // Tell CKEditor the tag is allowed, as well as any attribute on it. The 464 // "style" and "class" attributes are handled separately by CKEditor: 465 // they are disallowed even if you specify it in the list of allowed 466 // attributes, unless you state specific values for them that are 467 // allowed. Or, in this case: any value for them is allowed. 468 elseif ($attributes === TRUE) { 469 $allowed[$tag] = [ 470 'attributes' => TRUE, 471 'styles' => TRUE, 472 'classes' => TRUE, 473 ]; 474 // We've just marked that any value for the "style" and "class" 475 // attributes is allowed. However, that may not be the case: the "*" 476 // tag may still apply restrictions. 477 // Since CKEditor's ACF follows the following principle: 478 // Once validated, an element or its property cannot be 479 // invalidated by another rule. 480 // That means that the most permissive setting wins. Which means that 481 // it will still be allowed by CKEditor, for instance, to define any 482 // style, no matter what the "*" tag's restrictions may be. If there 483 // is a setting for either the "style" or "class" attribute, it cannot 484 // possibly be more permissive than what was set above. Hence, inherit 485 // from the "*" tag where possible. 486 if (isset($html_restrictions['allowed']['*'])) { 487 $wildcard = $html_restrictions['allowed']['*']; 488 if (isset($wildcard['style'])) { 489 if (!is_array($wildcard['style'])) { 490 $allowed[$tag]['styles'] = $wildcard['style']; 491 } 492 else { 493 $allowed_styles = $get_attribute_values($wildcard['style'], TRUE); 494 if (isset($allowed_styles)) { 495 $allowed[$tag]['styles'] = $allowed_styles; 496 } 497 else { 498 unset($allowed[$tag]['styles']); 499 } 500 } 501 } 502 if (isset($wildcard['class'])) { 503 if (!is_array($wildcard['class'])) { 504 $allowed[$tag]['classes'] = $wildcard['class']; 505 } 506 else { 507 $allowed_classes = $get_attribute_values($wildcard['class'], TRUE); 508 if (isset($allowed_classes)) { 509 $allowed[$tag]['classes'] = $allowed_classes; 510 } 511 else { 512 unset($allowed[$tag]['classes']); 513 } 514 } 515 } 516 } 517 } 518 // Tell CKEditor the tag is allowed, along with some tags. 519 elseif (is_array($attributes)) { 520 // Set defaults (these will be overridden below if more specific 521 // values are present). 522 $allowed[$tag] = [ 523 'attributes' => FALSE, 524 'styles' => FALSE, 525 'classes' => FALSE, 526 ]; 527 // Configure allowed attributes, allowed "style" attribute values and 528 // allowed "class" attribute values. 529 // CKEditor only allows specific values for the "class" and "style" 530 // attributes; so ignore restrictions on other attributes, which 531 // Drupal filters may provide. 532 // NOTE: A Drupal contrib module can subclass this class, override the 533 // getConfig() method, and override the JavaScript at 534 // Drupal.editors.ckeditor to somehow make validation of values for 535 // attributes other than "class" and "style" work. 536 $allowed_attributes = array_filter($attributes, function ($value) { 537 return $value !== FALSE; 538 }); 539 if (count($allowed_attributes)) { 540 $allowed[$tag]['attributes'] = implode(',', array_keys($allowed_attributes)); 541 } 542 if (isset($allowed_attributes['style'])) { 543 if (is_bool($allowed_attributes['style'])) { 544 $allowed[$tag]['styles'] = $allowed_attributes['style']; 545 } 546 elseif (is_array($allowed_attributes['style'])) { 547 $allowed_classes = $get_attribute_values($allowed_attributes['style'], TRUE); 548 if (isset($allowed_classes)) { 549 $allowed[$tag]['styles'] = $allowed_classes; 550 } 551 } 552 } 553 if (isset($allowed_attributes['class'])) { 554 if (is_bool($allowed_attributes['class'])) { 555 $allowed[$tag]['classes'] = $allowed_attributes['class']; 556 } 557 elseif (is_array($allowed_attributes['class'])) { 558 $allowed_classes = $get_attribute_values($allowed_attributes['class'], TRUE); 559 if (isset($allowed_classes)) { 560 $allowed[$tag]['classes'] = $allowed_classes; 561 } 562 } 563 } 564 565 // Handle disallowed attributes analogously. However, to handle *dis- 566 // allowed* attribute values, we must look at *allowed* attributes' 567 // disallowed attribute values! After all, a disallowed attribute 568 // implies that all of its possible attribute values are disallowed, 569 // thus we must look at the disallowed attribute values on allowed 570 // attributes. 571 $disallowed_attributes = array_filter($attributes, function ($value) { 572 return $value === FALSE; 573 }); 574 if (count($disallowed_attributes)) { 575 // No need to blacklist the 'class' or 'style' attributes; CKEditor 576 // handles them separately (if no specific class or style attribute 577 // values are allowed, then those attributes are disallowed). 578 if (isset($disallowed_attributes['class'])) { 579 unset($disallowed_attributes['class']); 580 } 581 if (isset($disallowed_attributes['style'])) { 582 unset($disallowed_attributes['style']); 583 } 584 $disallowed[$tag]['attributes'] = implode(',', array_keys($disallowed_attributes)); 585 } 586 if (isset($allowed_attributes['style']) && is_array($allowed_attributes['style'])) { 587 $disallowed_styles = $get_attribute_values($allowed_attributes['style'], FALSE); 588 if (isset($disallowed_styles)) { 589 $disallowed[$tag]['styles'] = $disallowed_styles; 590 } 591 } 592 if (isset($allowed_attributes['class']) && is_array($allowed_attributes['class'])) { 593 $disallowed_classes = $get_attribute_values($allowed_attributes['class'], FALSE); 594 if (isset($disallowed_classes)) { 595 $disallowed[$tag]['classes'] = $disallowed_classes; 596 } 597 } 598 } 599 } 600 601 ksort($allowed); 602 ksort($disallowed); 603 604 return [$allowed, $disallowed]; 605 } 606 } 607 608} 609