Initial Drupal 11 with DDEV setup
This commit is contained in:
42
web/core/modules/editor/src/Ajax/EditorDialogSave.php
Normal file
42
web/core/modules/editor/src/Ajax/EditorDialogSave.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\Ajax;
|
||||
|
||||
use Drupal\Core\Ajax\CommandInterface;
|
||||
|
||||
/**
|
||||
* Provides an AJAX command for saving the contents of an editor dialog.
|
||||
*
|
||||
* This command is implemented in editor.dialog.js in
|
||||
* Drupal.AjaxCommands.prototype.editorDialogSave.
|
||||
*/
|
||||
class EditorDialogSave implements CommandInterface {
|
||||
|
||||
/**
|
||||
* An array of values that will be passed back to the editor by the dialog.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $values;
|
||||
|
||||
/**
|
||||
* Constructs an EditorDialogSave object.
|
||||
*
|
||||
* @param array $values
|
||||
* The values that should be passed to the form constructor in Drupal.
|
||||
*/
|
||||
public function __construct(array $values) {
|
||||
$this->values = $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function render() {
|
||||
return [
|
||||
'command' => 'editorDialogSave',
|
||||
'values' => $this->values,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
99
web/core/modules/editor/src/Annotation/Editor.php
Normal file
99
web/core/modules/editor/src/Annotation/Editor.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\Annotation;
|
||||
|
||||
use Drupal\Component\Annotation\Plugin;
|
||||
|
||||
/**
|
||||
* Defines an Editor annotation object.
|
||||
*
|
||||
* Plugin Namespace: Plugin\Editor
|
||||
*
|
||||
* Text editor plugin implementations need to define a plugin definition array
|
||||
* through annotation. These definition arrays may be altered through
|
||||
* hook_editor_info_alter(). The definition includes the following keys:
|
||||
*
|
||||
* - id: The unique, system-wide identifier of the text editor. Typically named
|
||||
* the same as the editor library.
|
||||
* - label: The human-readable name of the text editor, translated.
|
||||
* - supports_content_filtering: Whether the editor supports "allowed content
|
||||
* only" filtering.
|
||||
* - supports_inline_editing: Whether the editor supports the inline editing
|
||||
* provided by the Edit module.
|
||||
* - is_xss_safe: Whether this text editor is not vulnerable to XSS attacks.
|
||||
* - supported_element_types: On which form element #types this text editor is
|
||||
* capable of working.
|
||||
*
|
||||
* A complete sample plugin definition should be defined as in this example:
|
||||
*
|
||||
* @code
|
||||
* @Editor(
|
||||
* id = "my_editor",
|
||||
* label = @Translation("My Editor"),
|
||||
* supports_content_filtering = FALSE,
|
||||
* supports_inline_editing = FALSE,
|
||||
* is_xss_safe = FALSE,
|
||||
* supported_element_types = {
|
||||
* "textarea",
|
||||
* "textfield",
|
||||
* }
|
||||
* )
|
||||
* @endcode
|
||||
*
|
||||
* For a working example, see \Drupal\ckeditor5\Plugin\Editor\CKEditor5
|
||||
*
|
||||
* @see \Drupal\editor\Plugin\EditorPluginInterface
|
||||
* @see \Drupal\editor\Plugin\EditorBase
|
||||
* @see \Drupal\editor\Plugin\EditorManager
|
||||
* @see hook_editor_info_alter()
|
||||
* @see plugin_api
|
||||
*
|
||||
* @Annotation
|
||||
*/
|
||||
class Editor extends Plugin {
|
||||
|
||||
/**
|
||||
* The plugin ID.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* The human-readable name of the editor plugin.
|
||||
*
|
||||
* @var \Drupal\Core\Annotation\Translation
|
||||
*
|
||||
* @ingroup plugin_translatable
|
||||
*/
|
||||
public $label;
|
||||
|
||||
/**
|
||||
* Whether the editor supports "allowed content only" filtering.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $supports_content_filtering;
|
||||
|
||||
/**
|
||||
* Whether the editor supports the inline editing provided by the Edit module.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $supports_inline_editing;
|
||||
|
||||
/**
|
||||
* Whether this text editor is not vulnerable to XSS attacks.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $is_xss_safe;
|
||||
|
||||
/**
|
||||
* A list of element types this text editor supports.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public $supported_element_types;
|
||||
|
||||
}
|
||||
53
web/core/modules/editor/src/Attribute/Editor.php
Normal file
53
web/core/modules/editor/src/Attribute/Editor.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\Attribute;
|
||||
|
||||
use Drupal\Component\Plugin\Attribute\Plugin;
|
||||
use Drupal\Core\StringTranslation\TranslatableMarkup;
|
||||
|
||||
/**
|
||||
* Defines an Editor attribute object.
|
||||
*
|
||||
* Plugin Namespace: Plugin\Editor
|
||||
*
|
||||
* For a working example, see \Drupal\ckeditor5\Plugin\Editor\CKEditor5
|
||||
*
|
||||
* @see \Drupal\editor\Plugin\EditorPluginInterface
|
||||
* @see \Drupal\editor\Plugin\EditorBase
|
||||
* @see \Drupal\editor\Plugin\EditorManager
|
||||
* @see hook_editor_info_alter()
|
||||
* @see plugin_api
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
class Editor extends Plugin {
|
||||
|
||||
/**
|
||||
* Constructs an Editor object.
|
||||
*
|
||||
* @param string $id
|
||||
* The plugin ID.
|
||||
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $label
|
||||
* The human-readable name of the text editor, translated.
|
||||
* @param bool $supports_content_filtering
|
||||
* Whether the editor supports "allowed content only" filtering.
|
||||
* @param bool $supports_inline_editing
|
||||
* Whether the editor supports the inline editing provided by the Edit
|
||||
* module.
|
||||
* @param bool $is_xss_safe
|
||||
* Whether this text editor is not vulnerable to XSS attacks.
|
||||
* @param string[] $supported_element_types
|
||||
* On which form element #types this text editor is capable of working.
|
||||
* @param class-string|null $deriver
|
||||
* (optional) The deriver class.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly TranslatableMarkup $label,
|
||||
public readonly bool $supports_content_filtering,
|
||||
public readonly bool $supports_inline_editing,
|
||||
public readonly bool $is_xss_safe,
|
||||
public readonly array $supported_element_types,
|
||||
public readonly ?string $deriver = NULL,
|
||||
) {}
|
||||
|
||||
}
|
||||
24
web/core/modules/editor/src/EditorAccessControlHandler.php
Normal file
24
web/core/modules/editor/src/EditorAccessControlHandler.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor;
|
||||
|
||||
use Drupal\Core\Entity\EntityAccessControlHandler;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
|
||||
/**
|
||||
* Defines the access control handler for the text editor entity type.
|
||||
*
|
||||
* @see \Drupal\editor\Entity\Editor
|
||||
*/
|
||||
class EditorAccessControlHandler extends EntityAccessControlHandler {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function checkAccess(EntityInterface $editor, $operation, AccountInterface $account) {
|
||||
/** @var \Drupal\editor\EditorInterface $editor */
|
||||
return $editor->getFilterFormat()->access($operation, $account, TRUE);
|
||||
}
|
||||
|
||||
}
|
||||
50
web/core/modules/editor/src/EditorController.php
Normal file
50
web/core/modules/editor/src/EditorController.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor;
|
||||
|
||||
use Drupal\Core\Controller\ControllerBase;
|
||||
use Drupal\filter\FilterFormatInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Returns responses for Editor module routes.
|
||||
*/
|
||||
class EditorController extends ControllerBase {
|
||||
|
||||
/**
|
||||
* Apply the necessary XSS filtering for using a certain text format's editor.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The current request object.
|
||||
* @param \Drupal\filter\FilterFormatInterface $filter_format
|
||||
* The text format whose text editor (if any) will be used.
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\JsonResponse
|
||||
* A JSON response containing the XSS-filtered value.
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||
* Thrown if no value to filter is specified.
|
||||
*
|
||||
* @see editor_filter_xss()
|
||||
*/
|
||||
public function filterXss(Request $request, FilterFormatInterface $filter_format) {
|
||||
$value = $request->request->get('value');
|
||||
if (!isset($value)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
// The original_format parameter will only exist when switching text format.
|
||||
$original_format_id = $request->request->get('original_format_id');
|
||||
$original_format = NULL;
|
||||
if (isset($original_format_id)) {
|
||||
$original_format = $this->entityTypeManager()
|
||||
->getStorage('filter_format')
|
||||
->load($original_format_id);
|
||||
}
|
||||
|
||||
return new JsonResponse(editor_filter_xss($value, $filter_format, $original_format));
|
||||
}
|
||||
|
||||
}
|
||||
88
web/core/modules/editor/src/EditorInterface.php
Normal file
88
web/core/modules/editor/src/EditorInterface.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor;
|
||||
|
||||
use Drupal\Core\Config\Entity\ConfigEntityInterface;
|
||||
|
||||
/**
|
||||
* Provides an interface defining a text editor entity.
|
||||
*/
|
||||
interface EditorInterface extends ConfigEntityInterface {
|
||||
|
||||
/**
|
||||
* Returns whether this text editor has an associated filter format.
|
||||
*
|
||||
* A text editor may be created at the same time as the filter format it's
|
||||
* going to be associated with; in that case, no filter format object is
|
||||
* available yet.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the text editor has an associated filter format, FALSE otherwise.
|
||||
*/
|
||||
public function hasAssociatedFilterFormat();
|
||||
|
||||
/**
|
||||
* Returns the filter format this text editor is associated with.
|
||||
*
|
||||
* This could be NULL if the associated filter format is still being created.
|
||||
*
|
||||
* @see hasAssociatedFilterFormat()
|
||||
*
|
||||
* @return \Drupal\filter\FilterFormatInterface|null
|
||||
* The filter format this text editor is associated with.
|
||||
*/
|
||||
public function getFilterFormat();
|
||||
|
||||
/**
|
||||
* Returns the associated text editor plugin ID.
|
||||
*
|
||||
* @return string
|
||||
* The text editor plugin ID.
|
||||
*/
|
||||
public function getEditor();
|
||||
|
||||
/**
|
||||
* Set the text editor plugin ID.
|
||||
*
|
||||
* @param string $editor
|
||||
* The text editor plugin ID to set.
|
||||
*/
|
||||
public function setEditor($editor);
|
||||
|
||||
/**
|
||||
* Returns the text editor plugin-specific settings.
|
||||
*
|
||||
* @return array
|
||||
* A structured array containing all text editor settings.
|
||||
*/
|
||||
public function getSettings();
|
||||
|
||||
/**
|
||||
* Sets the text editor plugin-specific settings.
|
||||
*
|
||||
* @param array $settings
|
||||
* The structured array containing all text editor settings.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setSettings(array $settings);
|
||||
|
||||
/**
|
||||
* Returns the image upload settings.
|
||||
*
|
||||
* @return array
|
||||
* A structured array containing image upload settings.
|
||||
*/
|
||||
public function getImageUploadSettings();
|
||||
|
||||
/**
|
||||
* Sets the image upload settings.
|
||||
*
|
||||
* @param array $image_upload
|
||||
* The structured array containing image upload settings.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setImageUploadSettings(array $image_upload);
|
||||
|
||||
}
|
||||
141
web/core/modules/editor/src/EditorXssFilter/Standard.php
Normal file
141
web/core/modules/editor/src/EditorXssFilter/Standard.php
Normal file
@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\EditorXssFilter;
|
||||
|
||||
use Drupal\Component\Utility\Html;
|
||||
use Drupal\Component\Utility\Xss;
|
||||
use Drupal\filter\FilterFormatInterface;
|
||||
use Drupal\editor\EditorXssFilterInterface;
|
||||
|
||||
/**
|
||||
* Defines the standard text editor XSS filter.
|
||||
*/
|
||||
class Standard extends Xss implements EditorXssFilterInterface {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function filterXss($html, FilterFormatInterface $format, ?FilterFormatInterface $original_format = NULL) {
|
||||
// Apply XSS filtering, but disallow the <script>, <style>, <link>, <embed>
|
||||
// and <object> tags.
|
||||
//
|
||||
// The <script> and <style> tags are removed because their contents can be
|
||||
// malicious (and therefore they are inherently unsafe), whereas for all
|
||||
// other tags, only their attributes can make them malicious. Since
|
||||
// \Drupal\Component\Utility\Xss::filter() is used to protect against
|
||||
// malicious attributes, we do not remove tags.
|
||||
//
|
||||
// The exceptions to the above rule are <link>, <embed> and <object>:
|
||||
// - <link> because the href attribute allows the attacker to import CSS
|
||||
// using the HTTP(S) protocols which Xss::filter() considers safe by
|
||||
// default. The imported remote CSS is applied to the main document, thus
|
||||
// allowing for the same XSS attacks as a regular <style> tag.
|
||||
// - <embed> and <object> because these tags allow non-HTML applications or
|
||||
// content to be embedded using the src or data attributes, respectively.
|
||||
// This is safe in the case of HTML documents, but not in the case of
|
||||
// Flash objects for example, that may access/modify the main document
|
||||
// directly.
|
||||
// <iframe> is considered safe because it only allows HTML content to be
|
||||
// embedded, hence ensuring the same origin policy always applies.
|
||||
$dangerous_tags = ['script', 'style', 'link', 'embed', 'object'];
|
||||
|
||||
// Simply removing these five dangerous tags would bring safety, but also
|
||||
// user frustration: what if a text format is configured to allow <embed>,
|
||||
// for example? Then we would strip that tag, even though it is allowed,
|
||||
// thereby causing data loss!
|
||||
// Therefore, we want to be smarter still. We want to take into account
|
||||
// which HTML tags are allowed by the text format we're filtering for, and
|
||||
// if we're switching from another text format, we want to take that
|
||||
// format's allowed tags into account as well.
|
||||
// In other words: we only expect markup allowed in both the original and
|
||||
// the new format to continue to exist.
|
||||
$format_restrictions = $format->getHtmlRestrictions();
|
||||
if ($original_format !== NULL) {
|
||||
$original_format_restrictions = $original_format->getHtmlRestrictions();
|
||||
}
|
||||
|
||||
// Any tags that are explicitly allowed by the text format must be removed
|
||||
// from the list of default dangerous tags: if they're explicitly allowed,
|
||||
// then we must respect that configuration.
|
||||
// When switching from another format, we must use the intersection of
|
||||
// allowed tags: if either format is more restrictive, then the safety
|
||||
// expectations of *both* formats apply.
|
||||
$allowed_tags = self::getAllowedTags($format_restrictions);
|
||||
if ($original_format !== NULL) {
|
||||
$allowed_tags = array_intersect($allowed_tags, self::getAllowedTags($original_format_restrictions));
|
||||
}
|
||||
|
||||
// Don't remove dangerous tags that are explicitly allowed in both text
|
||||
// formats.
|
||||
$removed_tags = array_diff($dangerous_tags, $allowed_tags);
|
||||
|
||||
$output = static::filter($html, $removed_tags);
|
||||
|
||||
// Since data-attributes can contain encoded HTML markup that could be
|
||||
// decoded and interpreted by editors, we need to apply XSS filtering to
|
||||
// their contents.
|
||||
return static::filterXssDataAttributes($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a very permissive XSS/HTML filter to data-attributes.
|
||||
*
|
||||
* @param string $html
|
||||
* The string to apply the data-attributes filtering to.
|
||||
*
|
||||
* @return string
|
||||
* The filtered string.
|
||||
*/
|
||||
protected static function filterXssDataAttributes($html) {
|
||||
if (stristr($html, 'data-') !== FALSE) {
|
||||
$dom = Html::load($html);
|
||||
$xpath = new \DOMXPath($dom);
|
||||
foreach ($xpath->query('//@*[starts-with(name(.), "data-")]') as $node) {
|
||||
// The data-attributes contain an HTML-encoded value, so we need to
|
||||
// decode the value, apply XSS filtering and then re-save as encoded
|
||||
// value. There is no need to explicitly decode $node->value, since the
|
||||
// DOMAttr::value getter returns the decoded value.
|
||||
$value = Xss::filterAdmin($node->value);
|
||||
$node->value = Html::escape($value);
|
||||
}
|
||||
$html = Html::serialize($dom);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all allowed tags from a restrictions data structure.
|
||||
*
|
||||
* @param array|false $restrictions
|
||||
* Restrictions as returned by FilterInterface::getHTMLRestrictions().
|
||||
*
|
||||
* @return array
|
||||
* An array of allowed HTML tags.
|
||||
*
|
||||
* @see \Drupal\filter\Plugin\Filter\FilterInterface::getHTMLRestrictions()
|
||||
*/
|
||||
protected static function getAllowedTags($restrictions) {
|
||||
if ($restrictions === FALSE || !isset($restrictions['allowed'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allowed_tags = array_keys($restrictions['allowed']);
|
||||
// Exclude the wildcard tag, which is used to set attribute restrictions on
|
||||
// all tags simultaneously.
|
||||
$allowed_tags = array_diff($allowed_tags, ['*']);
|
||||
|
||||
return $allowed_tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static function needsRemoval(array $html_tags, $elem) {
|
||||
// This class uses a list of tags to remove instead of the normal list of
|
||||
// tags to allow.
|
||||
// @see static::filterXss()
|
||||
return !parent::needsRemoval($html_tags, $elem);
|
||||
}
|
||||
|
||||
}
|
||||
42
web/core/modules/editor/src/EditorXssFilterInterface.php
Normal file
42
web/core/modules/editor/src/EditorXssFilterInterface.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor;
|
||||
|
||||
use Drupal\filter\FilterFormatInterface;
|
||||
|
||||
/**
|
||||
* Defines an interface for text editor XSS (Cross-site scripting) filters.
|
||||
*/
|
||||
interface EditorXssFilterInterface {
|
||||
|
||||
/**
|
||||
* Filters HTML to prevent XSS attacks when a user edits it in a text editor.
|
||||
*
|
||||
* Should filter as minimally as possible, only to remove XSS attack vectors.
|
||||
*
|
||||
* Is only called when:
|
||||
* - loading a non-XSS-safe text editor for a $format that contains a filter
|
||||
* preventing XSS attacks (a FilterInterface::TYPE_HTML_RESTRICTOR filter):
|
||||
* if the output is safe, it should also be safe to edit.
|
||||
* - loading a non-XSS-safe text editor for a $format that doesn't contain a
|
||||
* filter preventing XSS attacks, but we're switching from a previous text
|
||||
* format ($original_format is not NULL) that did prevent XSS attacks: if
|
||||
* the output was previously safe, it should be safe to switch to another
|
||||
* text format and edit.
|
||||
*
|
||||
* @param string $html
|
||||
* The HTML to be filtered.
|
||||
* @param \Drupal\filter\FilterFormatInterface $format
|
||||
* The text format configuration entity. Provides context based upon which
|
||||
* one may want to adjust the filtering.
|
||||
* @param \Drupal\filter\FilterFormatInterface|null $original_format
|
||||
* (optional) The original text format configuration entity (when switching
|
||||
* text formats/editors). Also provides context based upon which one may
|
||||
* want to adjust the filtering.
|
||||
*
|
||||
* @return string
|
||||
* The filtered HTML that cannot cause any XSS anymore.
|
||||
*/
|
||||
public static function filterXss($html, FilterFormatInterface $format, ?FilterFormatInterface $original_format = NULL);
|
||||
|
||||
}
|
||||
129
web/core/modules/editor/src/Element.php
Normal file
129
web/core/modules/editor/src/Element.php
Normal file
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor;
|
||||
|
||||
use Drupal\Core\Security\TrustedCallbackInterface;
|
||||
use Drupal\editor\Entity\Editor;
|
||||
use Drupal\filter\Entity\FilterFormat;
|
||||
use Drupal\Component\Plugin\PluginManagerInterface;
|
||||
use Drupal\Core\Render\BubbleableMetadata;
|
||||
|
||||
/**
|
||||
* Defines a service for Text Editor's render elements.
|
||||
*/
|
||||
class Element implements TrustedCallbackInterface {
|
||||
|
||||
/**
|
||||
* The Text Editor plugin manager service.
|
||||
*
|
||||
* @var \Drupal\Component\Plugin\PluginManagerInterface
|
||||
*/
|
||||
protected $pluginManager;
|
||||
|
||||
/**
|
||||
* Constructs a new Element object.
|
||||
*
|
||||
* @param \Drupal\Component\Plugin\PluginManagerInterface $plugin_manager
|
||||
* The Text Editor plugin manager service.
|
||||
*/
|
||||
public function __construct(PluginManagerInterface $plugin_manager) {
|
||||
$this->pluginManager = $plugin_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function trustedCallbacks() {
|
||||
return ['preRenderTextFormat'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional #pre_render callback for 'text_format' elements.
|
||||
*/
|
||||
public function preRenderTextFormat(array $element) {
|
||||
// Allow modules to programmatically enforce no client-side editor by
|
||||
// setting the #editor property to FALSE.
|
||||
if (isset($element['#editor']) && !$element['#editor']) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
// \Drupal\filter\Element\TextFormat::processFormat() copies properties to
|
||||
// the expanded 'value' to the child element, including the #pre_render
|
||||
// property. Skip this text format widget, if it contains no 'format'.
|
||||
if (!isset($element['format'])) {
|
||||
return $element;
|
||||
}
|
||||
$format_ids = array_keys($element['format']['format']['#options']);
|
||||
|
||||
// Early-return if no text editor is associated with any of the text
|
||||
// formats.
|
||||
$editors = Editor::loadMultiple($format_ids);
|
||||
foreach ($editors as $key => $editor) {
|
||||
$definition = $this->pluginManager->getDefinition($editor->getEditor());
|
||||
if (!in_array($element['#base_type'], $definition['supported_element_types'])) {
|
||||
unset($editors[$key]);
|
||||
}
|
||||
}
|
||||
if (count($editors) === 0) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
// Use a hidden element for a single text format.
|
||||
$field_id = $element['value']['#id'];
|
||||
if (!$element['format']['format']['#access']) {
|
||||
// Use the first (and only) available text format.
|
||||
$format_id = $format_ids[0];
|
||||
$element['format']['editor'] = [
|
||||
'#type' => 'hidden',
|
||||
'#name' => $element['format']['format']['#name'],
|
||||
'#value' => $format_id,
|
||||
'#attributes' => [
|
||||
'data-editor-for' => $field_id,
|
||||
],
|
||||
];
|
||||
}
|
||||
// Otherwise, attach to text format selector.
|
||||
else {
|
||||
$element['format']['format']['#attributes']['class'][] = 'editor';
|
||||
$element['format']['format']['#attributes']['data-editor-for'] = $field_id;
|
||||
}
|
||||
|
||||
// Hide the text format's filters' guidelines of those text formats that
|
||||
// have a text editor associated: they're rather useless when using a text
|
||||
// editor.
|
||||
foreach ($editors as $format_id => $editor) {
|
||||
$element['format']['guidelines'][$format_id]['#access'] = FALSE;
|
||||
}
|
||||
|
||||
// Attach Text Editor module's (this module) library.
|
||||
$element['#attached']['library'][] = 'editor/drupal.editor';
|
||||
|
||||
// Attach attachments for all available editors.
|
||||
$element['#attached'] = BubbleableMetadata::mergeAttachments($element['#attached'], $this->pluginManager->getAttachments($format_ids));
|
||||
|
||||
// Apply XSS filters when editing content if necessary. Some types of text
|
||||
// editors cannot guarantee that the end user won't become a victim of XSS.
|
||||
if (!empty($element['value']['#value'])) {
|
||||
$original = $element['value']['#value'];
|
||||
$format = FilterFormat::load($element['format']['format']['#value']);
|
||||
|
||||
// Ensure XSS-safety for the current text format/editor.
|
||||
$filtered = editor_filter_xss($original, $format);
|
||||
if ($filtered !== FALSE) {
|
||||
$element['value']['#value'] = $filtered;
|
||||
}
|
||||
|
||||
// Only when the user has access to multiple text formats, we must add
|
||||
// data- attributes for the original value and change tracking, because
|
||||
// they are only necessary when the end user can switch between text
|
||||
// formats/editors.
|
||||
if ($element['format']['format']['#access']) {
|
||||
$element['value']['#attributes']['data-editor-value-is-changed'] = 'false';
|
||||
$element['value']['#attributes']['data-editor-value-original'] = $original;
|
||||
}
|
||||
}
|
||||
|
||||
return $element;
|
||||
}
|
||||
|
||||
}
|
||||
228
web/core/modules/editor/src/Entity/Editor.php
Normal file
228
web/core/modules/editor/src/Entity/Editor.php
Normal file
@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\Entity;
|
||||
|
||||
use Drupal\Core\Entity\Attribute\ConfigEntityType;
|
||||
use Drupal\Core\StringTranslation\TranslatableMarkup;
|
||||
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
|
||||
use Drupal\Core\Config\Entity\ConfigEntityBase;
|
||||
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
|
||||
use Drupal\editor\EditorAccessControlHandler;
|
||||
use Drupal\editor\EditorInterface;
|
||||
|
||||
/**
|
||||
* Defines the configured text editor entity.
|
||||
*
|
||||
* An Editor entity is created when a filter format entity (Text format) is
|
||||
* saved after selecting an editor plugin (eg: CKEditor). The ID of the
|
||||
* Editor entity will be same as the ID of the filter format entity in which
|
||||
* the editor plugin was selected.
|
||||
*/
|
||||
#[ConfigEntityType(
|
||||
id: 'editor',
|
||||
label: new TranslatableMarkup('Text editor'),
|
||||
label_collection: new TranslatableMarkup('Text editors'),
|
||||
label_singular: new TranslatableMarkup('text editor'),
|
||||
label_plural: new TranslatableMarkup('text editors'),
|
||||
entity_keys: [
|
||||
'id' => 'format',
|
||||
],
|
||||
handlers: [
|
||||
'access' => EditorAccessControlHandler::class,
|
||||
],
|
||||
label_count: [
|
||||
'singular' => '@count text editor',
|
||||
'plural' => '@count text editors',
|
||||
],
|
||||
constraints: [
|
||||
'RequiredConfigDependencies' => [
|
||||
'filter_format',
|
||||
],
|
||||
],
|
||||
config_export: [
|
||||
'format',
|
||||
'editor',
|
||||
'settings',
|
||||
'image_upload',
|
||||
],
|
||||
)]
|
||||
class Editor extends ConfigEntityBase implements EditorInterface {
|
||||
|
||||
/**
|
||||
* Machine name of the text format for this configured text editor.
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @see getFilterFormat()
|
||||
*/
|
||||
protected $format;
|
||||
|
||||
/**
|
||||
* The name (plugin ID) of the text editor.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $editor;
|
||||
|
||||
/**
|
||||
* The structured array of text editor plugin-specific settings.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $settings = [];
|
||||
|
||||
/**
|
||||
* The structured array of image upload settings.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $image_upload = [];
|
||||
|
||||
/**
|
||||
* The filter format this text editor is associated with.
|
||||
*
|
||||
* @var \Drupal\filter\FilterFormatInterface
|
||||
*/
|
||||
protected $filterFormat;
|
||||
|
||||
/**
|
||||
* @var \Drupal\Component\Plugin\PluginManagerInterface
|
||||
*/
|
||||
protected $editorPluginManager;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function id() {
|
||||
return $this->format;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function __construct(array $values, $entity_type) {
|
||||
parent::__construct($values, $entity_type);
|
||||
|
||||
try {
|
||||
$plugin = $this->editorPluginManager()->createInstance($this->editor);
|
||||
$this->settings += $plugin->getDefaultSettings();
|
||||
}
|
||||
catch (PluginNotFoundException) {
|
||||
// When a Text Editor plugin has gone missing, still allow the Editor
|
||||
// config entity to be constructed. The only difference is that default
|
||||
// settings are not added.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function label() {
|
||||
return $this->getFilterFormat()->label();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function calculateDependencies() {
|
||||
parent::calculateDependencies();
|
||||
// Create a dependency on the associated FilterFormat.
|
||||
$this->addDependency('config', $this->getFilterFormat()->getConfigDependencyName());
|
||||
// @todo use EntityWithPluginCollectionInterface so configuration between
|
||||
// config entity and dependency on provider is managed automatically.
|
||||
$definition = $this->editorPluginManager()->createInstance($this->editor)->getPluginDefinition();
|
||||
$this->addDependency('module', $definition['provider']);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function hasAssociatedFilterFormat() {
|
||||
return $this->format !== NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFilterFormat() {
|
||||
if (!$this->filterFormat) {
|
||||
$this->filterFormat = \Drupal::entityTypeManager()->getStorage('filter_format')->load($this->format);
|
||||
}
|
||||
return $this->filterFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the editor plugin manager.
|
||||
*
|
||||
* @return \Drupal\Component\Plugin\PluginManagerInterface
|
||||
* The editor plugin manager instance.
|
||||
*/
|
||||
protected function editorPluginManager() {
|
||||
if (!$this->editorPluginManager) {
|
||||
$this->editorPluginManager = \Drupal::service('plugin.manager.editor');
|
||||
}
|
||||
|
||||
return $this->editorPluginManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEditor() {
|
||||
return $this->editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function setEditor($editor) {
|
||||
$this->editor = $editor;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSettings() {
|
||||
return $this->settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function setSettings(array $settings) {
|
||||
$this->settings = $settings;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getImageUploadSettings() {
|
||||
return $this->image_upload;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function setImageUploadSettings(array $image_upload_settings) {
|
||||
$this->image_upload = $image_upload_settings;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes all valid choices for the "image_upload.scheme" setting.
|
||||
*
|
||||
* @see editor.schema.yml
|
||||
*
|
||||
* @return string[]
|
||||
* All valid choices.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function getValidStreamWrappers(): array {
|
||||
return array_keys(\Drupal::service('stream_wrapper_manager')->getNames(StreamWrapperInterface::WRITE_VISIBLE));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\EventSubscriber;
|
||||
|
||||
use Drupal\config_translation\ConfigEntityMapperInterface;
|
||||
use Drupal\config_translation\Event\ConfigMapperPopulateEvent;
|
||||
use Drupal\config_translation\Event\ConfigTranslationEvents;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Drupal\Core\Config\ConfigFactoryInterface;
|
||||
|
||||
/**
|
||||
* Adds configuration names to configuration mapper on POPULATE_MAPPER event.
|
||||
*/
|
||||
class EditorConfigTranslationSubscriber implements EventSubscriberInterface {
|
||||
|
||||
/**
|
||||
* The config factory.
|
||||
*
|
||||
* @var \Drupal\Core\Config\ConfigFactoryInterface
|
||||
*/
|
||||
protected $configFactory;
|
||||
|
||||
/**
|
||||
* EditorConfigTranslationSubscriber constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
|
||||
* The factory for configuration objects.
|
||||
*/
|
||||
public function __construct(ConfigFactoryInterface $config_factory) {
|
||||
$this->configFactory = $config_factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function getSubscribedEvents(): array {
|
||||
$events = [];
|
||||
if (class_exists('Drupal\config_translation\Event\ConfigTranslationEvents')) {
|
||||
$events[ConfigTranslationEvents::POPULATE_MAPPER][] = ['addConfigNames'];
|
||||
}
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reacts to the populating of a configuration mapper.
|
||||
*
|
||||
* @param \Drupal\config_translation\Event\ConfigMapperPopulateEvent $event
|
||||
* The configuration mapper event.
|
||||
*/
|
||||
public function addConfigNames(ConfigMapperPopulateEvent $event) {
|
||||
$mapper = $event->getMapper();
|
||||
if ($mapper instanceof ConfigEntityMapperInterface && $mapper->getType() == 'filter_format') {
|
||||
$editor_config_name = 'editor.editor.' . $mapper->getEntity()->id();
|
||||
// Only add the text editor config if it exists, otherwise we assume no
|
||||
// editor has been set for this text format.
|
||||
if (!$this->configFactory->get($editor_config_name)->isNew()) {
|
||||
$mapper->addConfigName($editor_config_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
351
web/core/modules/editor/src/Hook/EditorHooks.php
Normal file
351
web/core/modules/editor/src/Hook/EditorHooks.php
Normal file
@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\Hook;
|
||||
|
||||
use Drupal\Core\StringTranslation\StringTranslationTrait;
|
||||
use Drupal\editor\Entity\Editor;
|
||||
use Drupal\filter\FilterFormatInterface;
|
||||
use Drupal\Core\Entity\FieldableEntityInterface;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Form\SubformState;
|
||||
use Drupal\Core\Render\Element;
|
||||
use Drupal\Core\Form\FormStateInterface;
|
||||
use Drupal\Core\StringTranslation\TranslatableMarkup;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\Core\Routing\RouteMatchInterface;
|
||||
use Drupal\Core\Hook\Attribute\Hook;
|
||||
|
||||
/**
|
||||
* Hook implementations for editor.
|
||||
*/
|
||||
class EditorHooks {
|
||||
|
||||
use StringTranslationTrait;
|
||||
|
||||
/**
|
||||
* Implements hook_help().
|
||||
*/
|
||||
#[Hook('help')]
|
||||
public function help($route_name, RouteMatchInterface $route_match): ?string {
|
||||
switch ($route_name) {
|
||||
case 'help.page.editor':
|
||||
$output = '';
|
||||
$output .= '<h2>' . $this->t('About') . '</h2>';
|
||||
$output .= '<p>' . $this->t('The Text Editor module provides a framework that other modules (such as <a href=":ckeditor5">CKEditor5 module</a>) can use to provide toolbars and other functionality that allow users to format text more easily than typing HTML tags directly. For more information, see the <a href=":documentation">online documentation for the Text Editor module</a>.', [
|
||||
':documentation' => 'https://www.drupal.org/documentation/modules/editor',
|
||||
':ckeditor5' => \Drupal::moduleHandler()->moduleExists('ckeditor5') ? Url::fromRoute('help.page', [
|
||||
'name' => 'ckeditor5',
|
||||
])->toString() : '#',
|
||||
]) . '</p>';
|
||||
$output .= '<h2>' . $this->t('Uses') . '</h2>';
|
||||
$output .= '<dl>';
|
||||
$output .= '<dt>' . $this->t('Installing text editors') . '</dt>';
|
||||
$output .= '<dd>' . $this->t('The Text Editor module provides a framework for managing editors. To use it, you also need to install a text editor. This can either be the core <a href=":ckeditor5">CKEditor5 module</a>, which can be installed on the <a href=":extend">Extend page</a>, or a contributed module for any other text editor. When installing a contributed text editor module, be sure to check the installation instructions, because you will most likely need to download an external library as well as the Drupal module.', [
|
||||
':ckeditor5' => \Drupal::moduleHandler()->moduleExists('ckeditor5') ? Url::fromRoute('help.page', [
|
||||
'name' => 'ckeditor5',
|
||||
])->toString() : '#',
|
||||
':extend' => Url::fromRoute('system.modules_list')->toString(),
|
||||
]) . '</dd>';
|
||||
$output .= '<dt>' . $this->t('Enabling a text editor for a text format') . '</dt>';
|
||||
$output .= '<dd>' . $this->t('On the <a href=":formats">Text formats and editors page</a> you can see which text editor is associated with each text format. You can change this by clicking on the <em>Configure</em> link, and then choosing a text editor or <em>none</em> from the <em>Text editor</em> drop-down list. The text editor will then be displayed with any text field for which this text format is chosen.', [':formats' => Url::fromRoute('filter.admin_overview')->toString()]) . '</dd>';
|
||||
$output .= '<dt>' . $this->t('Configuring a text editor') . '</dt>';
|
||||
$output .= '<dd>' . $this->t('Once a text editor is associated with a text format, you can configure it by clicking on the <em>Configure</em> link for this format. Depending on the specific text editor, you can configure it for example by adding buttons to its toolbar. Typically these buttons provide formatting or editing tools, and they often insert HTML tags into the field source. For details, see the help page of the specific text editor.') . '</dd>';
|
||||
$output .= '<dt>' . $this->t('Using different text editors and formats') . '</dt>';
|
||||
$output .= '<dd>' . $this->t('If you change the text format on a text field, the text editor will change as well because the text editor configuration is associated with the individual text format. This allows the use of the same text editor with different options for different text formats. It also allows users to choose between text formats with different text editors if they are installed.') . '</dd>';
|
||||
$output .= '</dl>';
|
||||
return $output;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_menu_links_discovered_alter().
|
||||
*
|
||||
* Rewrites the menu entries for filter module that relate to the
|
||||
* configuration of text editors.
|
||||
*/
|
||||
#[Hook('menu_links_discovered_alter')]
|
||||
public function menuLinksDiscoveredAlter(array &$links): void {
|
||||
$links['filter.admin_overview']['title'] = new TranslatableMarkup('Text formats and editors');
|
||||
$links['filter.admin_overview']['description'] = new TranslatableMarkup('Select and configure text editors, and how content is filtered when displayed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_element_info_alter().
|
||||
*
|
||||
* Extends the functionality of text_format elements (provided by Filter
|
||||
* module), so that selecting a text format notifies a client-side text editor
|
||||
* when it should be enabled or disabled.
|
||||
*
|
||||
* @see \Drupal\filter\Element\TextFormat
|
||||
*/
|
||||
#[Hook('element_info_alter')]
|
||||
public function elementInfoAlter(&$types): void {
|
||||
$types['text_format']['#pre_render'][] = 'element.editor:preRenderTextFormat';
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_form_FORM_ID_alter().
|
||||
*/
|
||||
#[Hook('form_filter_admin_overview_alter')]
|
||||
public function formFilterAdminOverviewAlter(&$form, FormStateInterface $form_state) : void {
|
||||
// @todo Cleanup column injection: https://www.drupal.org/node/1876718.
|
||||
// Splice in the column for "Text editor" into the header.
|
||||
$position = array_search('name', $form['formats']['#header']) + 1;
|
||||
$start = array_splice($form['formats']['#header'], 0, $position, ['editor' => $this->t('Text editor')]);
|
||||
$form['formats']['#header'] = array_merge($start, $form['formats']['#header']);
|
||||
// Then splice in the name of each text editor for each text format.
|
||||
$editors = \Drupal::service('plugin.manager.editor')->getDefinitions();
|
||||
foreach (Element::children($form['formats']) as $format_id) {
|
||||
$editor = \Drupal::entityTypeManager()->getStorage('editor')->load($format_id);
|
||||
$editor_name = $editor && isset($editors[$editor->getEditor()]) ? $editors[$editor->getEditor()]['label'] : '—';
|
||||
$editor_column['editor'] = ['#markup' => $editor_name];
|
||||
$position = array_search('name', array_keys($form['formats'][$format_id])) + 1;
|
||||
$start = array_splice($form['formats'][$format_id], 0, $position, $editor_column);
|
||||
$form['formats'][$format_id] = array_merge($start, $form['formats'][$format_id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_form_BASE_FORM_ID_alter() for \Drupal\filter\FilterFormatEditForm.
|
||||
*/
|
||||
#[Hook('form_filter_format_form_alter')]
|
||||
public function formFilterFormatFormAlter(&$form, FormStateInterface $form_state) : void {
|
||||
$editor = $form_state->get('editor');
|
||||
if ($editor === NULL) {
|
||||
$format = $form_state->getFormObject()->getEntity();
|
||||
$format_id = $format->isNew() ? NULL : $format->id();
|
||||
$editor = $format_id ? \Drupal::entityTypeManager()->getStorage('editor')->load($format_id) : NULL;
|
||||
$form_state->set('editor', $editor);
|
||||
}
|
||||
// Associate a text editor with this text format.
|
||||
$manager = \Drupal::service('plugin.manager.editor');
|
||||
$editor_options = $manager->listOptions();
|
||||
$form['editor'] = ['#weight' => -9];
|
||||
$form['editor']['editor'] = [
|
||||
'#type' => 'select',
|
||||
'#title' => $this->t('Text editor'),
|
||||
'#options' => $editor_options,
|
||||
'#empty_option' => $this->t('None'),
|
||||
'#default_value' => $editor ? $editor->getEditor() : '',
|
||||
'#ajax' => [
|
||||
'trigger_as' => [
|
||||
'name' => 'editor_configure',
|
||||
],
|
||||
'callback' => 'editor_form_filter_admin_form_ajax',
|
||||
'wrapper' => 'editor-settings-wrapper',
|
||||
],
|
||||
'#weight' => -10,
|
||||
];
|
||||
$form['editor']['configure'] = [
|
||||
'#type' => 'submit',
|
||||
'#name' => 'editor_configure',
|
||||
'#value' => $this->t('Configure'),
|
||||
'#limit_validation_errors' => [
|
||||
[
|
||||
'editor',
|
||||
],
|
||||
],
|
||||
'#submit' => [
|
||||
'editor_form_filter_admin_format_editor_configure',
|
||||
],
|
||||
'#ajax' => [
|
||||
'callback' => 'editor_form_filter_admin_form_ajax',
|
||||
'wrapper' => 'editor-settings-wrapper',
|
||||
],
|
||||
'#weight' => -10,
|
||||
'#attributes' => [
|
||||
'class' => [
|
||||
'js-hide',
|
||||
],
|
||||
],
|
||||
];
|
||||
// If there aren't any options (other than "None"), disable the select list.
|
||||
if (empty($editor_options)) {
|
||||
$form['editor']['editor']['#disabled'] = TRUE;
|
||||
$form['editor']['editor']['#description'] = $this->t('This option is disabled because no modules that provide a text editor are currently enabled.');
|
||||
}
|
||||
$form['editor']['settings'] = [
|
||||
'#tree' => TRUE,
|
||||
'#weight' => -8,
|
||||
'#type' => 'container',
|
||||
'#id' => 'editor-settings-wrapper',
|
||||
];
|
||||
// Add editor-specific validation and submit handlers.
|
||||
if ($editor) {
|
||||
/** @var \Drupal\editor\Plugin\EditorPluginInterface $plugin */
|
||||
$plugin = $manager->createInstance($editor->getEditor());
|
||||
$form_state->set('editor_plugin', $plugin);
|
||||
$form['editor']['settings']['subform'] = [];
|
||||
$subform_state = SubformState::createForSubform($form['editor']['settings']['subform'], $form, $form_state);
|
||||
$form['editor']['settings']['subform'] = $plugin->buildConfigurationForm($form['editor']['settings']['subform'], $subform_state);
|
||||
$form['editor']['settings']['subform']['#parents'] = ['editor', 'settings'];
|
||||
}
|
||||
$form['#validate'][] = 'editor_form_filter_admin_format_validate';
|
||||
$form['actions']['submit']['#submit'][] = 'editor_form_filter_admin_format_submit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_entity_insert().
|
||||
*/
|
||||
#[Hook('entity_insert')]
|
||||
public function entityInsert(EntityInterface $entity): void {
|
||||
// Only act on content entities.
|
||||
if (!$entity instanceof FieldableEntityInterface) {
|
||||
return;
|
||||
}
|
||||
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
|
||||
foreach ($referenced_files_by_field as $uuids) {
|
||||
_editor_record_file_usage($uuids, $entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_entity_update().
|
||||
*/
|
||||
#[Hook('entity_update')]
|
||||
public function entityUpdate(EntityInterface $entity): void {
|
||||
// Only act on content entities.
|
||||
if (!$entity instanceof FieldableEntityInterface) {
|
||||
return;
|
||||
}
|
||||
// On new revisions, all files are considered to be a new usage and no
|
||||
// deletion of previous file usages are necessary.
|
||||
if ($entity->getOriginal() && $entity->getRevisionId() != $entity->getOriginal()->getRevisionId()) {
|
||||
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
|
||||
foreach ($referenced_files_by_field as $uuids) {
|
||||
_editor_record_file_usage($uuids, $entity);
|
||||
}
|
||||
}
|
||||
else {
|
||||
$original_uuids_by_field = !$entity->getOriginal() ? [] : _editor_get_file_uuids_by_field($entity->getOriginal());
|
||||
$uuids_by_field = _editor_get_file_uuids_by_field($entity);
|
||||
// Detect file usages that should be incremented.
|
||||
foreach ($uuids_by_field as $field => $uuids) {
|
||||
$original_uuids = $original_uuids_by_field[$field] ?? [];
|
||||
if ($added_files = array_diff($uuids_by_field[$field], $original_uuids)) {
|
||||
_editor_record_file_usage($added_files, $entity);
|
||||
}
|
||||
}
|
||||
// Detect file usages that should be decremented.
|
||||
foreach ($original_uuids_by_field as $field => $uuids) {
|
||||
$removed_files = array_diff($original_uuids_by_field[$field], $uuids_by_field[$field]);
|
||||
_editor_delete_file_usage($removed_files, $entity, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_entity_delete().
|
||||
*/
|
||||
#[Hook('entity_delete')]
|
||||
public function entityDelete(EntityInterface $entity): void {
|
||||
// Only act on content entities.
|
||||
if (!$entity instanceof FieldableEntityInterface) {
|
||||
return;
|
||||
}
|
||||
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
|
||||
foreach ($referenced_files_by_field as $uuids) {
|
||||
_editor_delete_file_usage($uuids, $entity, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_entity_revision_delete().
|
||||
*/
|
||||
#[Hook('entity_revision_delete')]
|
||||
public function entityRevisionDelete(EntityInterface $entity): void {
|
||||
// Only act on content entities.
|
||||
if (!$entity instanceof FieldableEntityInterface) {
|
||||
return;
|
||||
}
|
||||
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
|
||||
foreach ($referenced_files_by_field as $uuids) {
|
||||
_editor_delete_file_usage($uuids, $entity, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_file_download().
|
||||
*
|
||||
* @see file_file_download()
|
||||
* @see file_get_file_references()
|
||||
*/
|
||||
#[Hook('file_download')]
|
||||
public function fileDownload($uri): array|int|null {
|
||||
// Get the file record based on the URI. If not in the database just return.
|
||||
/** @var \Drupal\file\FileRepositoryInterface $file_repository */
|
||||
$file_repository = \Drupal::service('file.repository');
|
||||
$file = $file_repository->loadByUri($uri);
|
||||
if (!$file) {
|
||||
return NULL;
|
||||
}
|
||||
// Temporary files are handled by file_file_download(), so nothing to do
|
||||
// here about them.
|
||||
// @see file_file_download()
|
||||
// Find out if any editor-backed field contains the file.
|
||||
$usage_list = \Drupal::service('file.usage')->listUsage($file);
|
||||
// Stop processing if there are no references in order to avoid returning
|
||||
// headers for files controlled by other modules. Make an exception for
|
||||
// temporary files where the host entity has not yet been saved (for
|
||||
// example, an image preview on a node creation form) in which case, allow
|
||||
// download by the file's owner.
|
||||
if (empty($usage_list['editor']) && ($file->isPermanent() || $file->getOwnerId() != \Drupal::currentUser()->id())) {
|
||||
return NULL;
|
||||
}
|
||||
// Editor.module MUST NOT call $file->access() here (like
|
||||
// file_file_download() does) as checking the 'download' access to a file
|
||||
// entity would end up in FileAccessControlHandler->checkAccess() and
|
||||
// ->getFileReferences(), which calls file_get_file_references(). This
|
||||
// latter one would allow downloading files only handled by the file.module,
|
||||
// which is exactly not the case right here. So instead we must check if the
|
||||
// current user is allowed to view any of the entities that reference the
|
||||
// image using the 'editor' module.
|
||||
if ($file->isPermanent()) {
|
||||
$referencing_entity_is_accessible = FALSE;
|
||||
$references = empty($usage_list['editor']) ? [] : $usage_list['editor'];
|
||||
foreach ($references as $entity_type => $entity_ids_usage_count) {
|
||||
$referencing_entities = \Drupal::entityTypeManager()->getStorage($entity_type)->loadMultiple(array_keys($entity_ids_usage_count));
|
||||
/** @var \Drupal\Core\Entity\EntityInterface $referencing_entity */
|
||||
foreach ($referencing_entities as $referencing_entity) {
|
||||
if ($referencing_entity->access('view', NULL, TRUE)->isAllowed()) {
|
||||
$referencing_entity_is_accessible = TRUE;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$referencing_entity_is_accessible) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
// Access is granted.
|
||||
$headers = $file->getDownloadHeaders();
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_ENTITY_TYPE_presave().
|
||||
*
|
||||
* Synchronizes the editor status to its paired text format status.
|
||||
*
|
||||
* @todo remove in https://www.drupal.org/project/drupal/issues/3231354.
|
||||
*/
|
||||
#[Hook('filter_format_presave')]
|
||||
public function filterFormatPresave(FilterFormatInterface $format): void {
|
||||
// The text format being created cannot have a text editor yet.
|
||||
if ($format->isNew()) {
|
||||
return;
|
||||
}
|
||||
/** @var \Drupal\filter\FilterFormatInterface $original */
|
||||
$original = \Drupal::entityTypeManager()->getStorage('filter_format')->loadUnchanged($format->getOriginalId());
|
||||
// If the text format status is the same, return early.
|
||||
if (($status = $format->status()) === $original->status()) {
|
||||
return;
|
||||
}
|
||||
/** @var \Drupal\editor\EditorInterface $editor */
|
||||
if ($editor = Editor::load($format->id())) {
|
||||
$editor->setStatus($status)->save();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
50
web/core/modules/editor/src/Plugin/EditorBase.php
Normal file
50
web/core/modules/editor/src/Plugin/EditorBase.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\Plugin;
|
||||
|
||||
use Drupal\Core\Form\FormStateInterface;
|
||||
use Drupal\Core\Plugin\PluginBase;
|
||||
|
||||
/**
|
||||
* Defines a base class from which other modules providing editors may extend.
|
||||
*
|
||||
* This class provides default implementations of the EditorPluginInterface so
|
||||
* that classes extending this one do not need to implement every method.
|
||||
*
|
||||
* Plugins extending this class need to specify an annotation containing the
|
||||
* plugin definition so the plugin can be discovered.
|
||||
*
|
||||
* @see \Drupal\editor\Annotation\Editor
|
||||
* @see \Drupal\editor\Plugin\EditorPluginInterface
|
||||
* @see \Drupal\editor\Plugin\EditorManager
|
||||
* @see plugin_api
|
||||
*/
|
||||
abstract class EditorBase extends PluginBase implements EditorPluginInterface {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getDefaultSettings() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
|
||||
}
|
||||
|
||||
}
|
||||
109
web/core/modules/editor/src/Plugin/EditorManager.php
Normal file
109
web/core/modules/editor/src/Plugin/EditorManager.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\Plugin;
|
||||
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\Core\Plugin\DefaultPluginManager;
|
||||
use Drupal\Core\Cache\CacheBackendInterface;
|
||||
use Drupal\Core\Extension\ModuleHandlerInterface;
|
||||
use Drupal\editor\Attribute\Editor;
|
||||
|
||||
/**
|
||||
* Configurable text editor manager.
|
||||
*
|
||||
* @see \Drupal\editor\Annotation\Editor
|
||||
* @see \Drupal\editor\Plugin\EditorPluginInterface
|
||||
* @see \Drupal\editor\Plugin\EditorBase
|
||||
* @see plugin_api
|
||||
*/
|
||||
class EditorManager extends DefaultPluginManager {
|
||||
|
||||
/**
|
||||
* Static cache of attachments.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $attachments = ['library' => []];
|
||||
|
||||
/**
|
||||
* Editors.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $editors = [];
|
||||
|
||||
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler, protected ?EntityTypeManagerInterface $entityTypeManager = NULL) {
|
||||
parent::__construct('Plugin/Editor', $namespaces, $module_handler, EditorPluginInterface::class, Editor::class, 'Drupal\editor\Annotation\Editor');
|
||||
$this->alterInfo('editor_info');
|
||||
$this->setCacheBackend($cache_backend, 'editor_plugins');
|
||||
if ($this->entityTypeManager === NULL) {
|
||||
@trigger_error('Calling ' . __METHOD__ . '() without the $entityTypeManager argument is deprecated in drupal:11.2.0 and will be required in drupal:12.0.0. See https://www.drupal.org/project/drupal/issues/3447794', E_USER_DEPRECATED);
|
||||
$this->entityTypeManager = \Drupal::entityTypeManager();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates a key-value pair of available text editors.
|
||||
*
|
||||
* @return array
|
||||
* An array of translated text editor labels, keyed by ID.
|
||||
*/
|
||||
public function listOptions() {
|
||||
$options = [];
|
||||
foreach ($this->getDefinitions() as $key => $definition) {
|
||||
$options[$key] = $definition['label'];
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves text editor libraries and JavaScript settings.
|
||||
*
|
||||
* @param array $format_ids
|
||||
* An array of format IDs as returned by array_keys(filter_formats()).
|
||||
*
|
||||
* @return array
|
||||
* An array of attachments, for use with #attached.
|
||||
*
|
||||
* @see \Drupal\Core\Render\AttachmentsResponseProcessorInterface::processAttachments()
|
||||
*/
|
||||
public function getAttachments(array $format_ids) {
|
||||
$settings = $this->attachments['drupalSettings'] ?? [];
|
||||
|
||||
if (($editor_ids_to_load = array_diff($format_ids, array_keys($this->editors)))) {
|
||||
$editors = $this->entityTypeManager->getStorage('editor')
|
||||
->loadMultiple($editor_ids_to_load);
|
||||
// Statically cache the editors and include NULL entries for formats that
|
||||
// do not have editors.
|
||||
$this->editors += $editors + array_fill_keys($editor_ids_to_load, NULL);
|
||||
foreach ($editors as $format_id => $editor) {
|
||||
$plugin = $this->createInstance($editor->getEditor());
|
||||
$plugin_definition = $plugin->getPluginDefinition();
|
||||
|
||||
// Libraries.
|
||||
$this->attachments['library'] = array_merge($this->attachments['library'], $plugin->getLibraries($editor));
|
||||
|
||||
// Format-specific JavaScript settings.
|
||||
$settings['editor']['formats'][$format_id] = [
|
||||
'format' => $format_id,
|
||||
'editor' => $editor->getEditor(),
|
||||
'editorSettings' => $plugin->getJSSettings($editor),
|
||||
'editorSupportsContentFiltering' => $plugin_definition['supports_content_filtering'],
|
||||
'isXssSafe' => $plugin_definition['is_xss_safe'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Allow other modules to alter all JavaScript settings.
|
||||
$this->moduleHandler->alter('editor_js_settings', $settings);
|
||||
|
||||
if (empty($this->attachments['library']) && empty($settings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->attachments['drupalSettings'] = $settings;
|
||||
|
||||
return $this->attachments;
|
||||
}
|
||||
|
||||
}
|
||||
75
web/core/modules/editor/src/Plugin/EditorPluginInterface.php
Normal file
75
web/core/modules/editor/src/Plugin/EditorPluginInterface.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\Plugin;
|
||||
|
||||
use Drupal\Component\Plugin\PluginInspectionInterface;
|
||||
use Drupal\Core\Plugin\PluginFormInterface;
|
||||
use Drupal\editor\Entity\Editor;
|
||||
|
||||
/**
|
||||
* Defines an interface for configurable text editors.
|
||||
*
|
||||
* Modules implementing this interface may want to extend the EditorBase class,
|
||||
* which provides default implementations of each method where appropriate.
|
||||
*
|
||||
* If the editor's behavior depends on extensive options and/or external data,
|
||||
* then the implementing module can choose to provide a separate, global
|
||||
* configuration page rather than per-text-format settings. In that case, this
|
||||
* form should provide a link to the separate settings page.
|
||||
*
|
||||
* @see \Drupal\editor\Annotation\Editor
|
||||
* @see \Drupal\editor\Plugin\EditorBase
|
||||
* @see \Drupal\editor\Plugin\EditorManager
|
||||
* @see plugin_api
|
||||
*/
|
||||
interface EditorPluginInterface extends PluginInspectionInterface, PluginFormInterface {
|
||||
|
||||
/**
|
||||
* Returns the default settings for this configurable text editor.
|
||||
*
|
||||
* @return array
|
||||
* An array of settings as they would be stored by a configured text editor
|
||||
* entity (\Drupal\editor\Entity\Editor).
|
||||
*/
|
||||
public function getDefaultSettings();
|
||||
|
||||
/**
|
||||
* Returns JavaScript settings to be attached.
|
||||
*
|
||||
* Most text editors use JavaScript to provide a WYSIWYG or toolbar on the
|
||||
* client-side interface. This method can be used to convert internal settings
|
||||
* of the text editor into JavaScript variables that will be accessible when
|
||||
* the text editor is loaded.
|
||||
*
|
||||
* @param \Drupal\editor\Entity\Editor $editor
|
||||
* A configured text editor object.
|
||||
*
|
||||
* @return array
|
||||
* An array of settings that will be added to the page for use by this text
|
||||
* editor's JavaScript integration.
|
||||
*
|
||||
* @see \Drupal\Core\Render\AttachmentsResponseProcessorInterface::processAttachments()
|
||||
* @see EditorManager::getAttachments()
|
||||
*/
|
||||
public function getJSSettings(Editor $editor);
|
||||
|
||||
/**
|
||||
* Returns libraries to be attached.
|
||||
*
|
||||
* Because this is a method, plugins can dynamically choose to attach a
|
||||
* different library for different configurations, instead of being forced to
|
||||
* always use the same method.
|
||||
*
|
||||
* @param \Drupal\editor\Entity\Editor $editor
|
||||
* A configured text editor object.
|
||||
*
|
||||
* @return array
|
||||
* An array of libraries that will be added to the page for use by this text
|
||||
* editor.
|
||||
*
|
||||
* @see \Drupal\Core\Render\AttachmentsResponseProcessorInterface::processAttachments()
|
||||
* @see EditorManager::getAttachments()
|
||||
*/
|
||||
public function getLibraries(Editor $editor);
|
||||
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\editor\Plugin\Filter;
|
||||
|
||||
use Drupal\Component\Utility\Html;
|
||||
use Drupal\Core\Entity\EntityRepositoryInterface;
|
||||
use Drupal\Core\Image\ImageFactory;
|
||||
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
|
||||
use Drupal\Core\StringTranslation\TranslatableMarkup;
|
||||
use Drupal\file\FileInterface;
|
||||
use Drupal\filter\Attribute\Filter;
|
||||
use Drupal\filter\FilterProcessResult;
|
||||
use Drupal\filter\Plugin\FilterBase;
|
||||
use Drupal\filter\Plugin\FilterInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Provides a filter to track images uploaded via a Text Editor.
|
||||
*
|
||||
* Generates file URLs and associates the cache tags of referenced files.
|
||||
*/
|
||||
#[Filter(
|
||||
id: "editor_file_reference",
|
||||
title: new TranslatableMarkup("Track images uploaded via a Text Editor"),
|
||||
description: new TranslatableMarkup("Ensures that the latest versions of images uploaded via a Text Editor are displayed, along with their dimensions."),
|
||||
type: FilterInterface::TYPE_TRANSFORM_REVERSIBLE
|
||||
)]
|
||||
class EditorFileReference extends FilterBase implements ContainerFactoryPluginInterface {
|
||||
|
||||
/**
|
||||
* The entity repository.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityRepositoryInterface
|
||||
*/
|
||||
protected $entityRepository;
|
||||
|
||||
/**
|
||||
* The image factory.
|
||||
*
|
||||
* @var \Drupal\Core\Image\ImageFactory
|
||||
*/
|
||||
protected $imageFactory;
|
||||
|
||||
/**
|
||||
* Constructs a \Drupal\editor\Plugin\Filter\EditorFileReference object.
|
||||
*
|
||||
* @param array $configuration
|
||||
* A configuration array containing information about the plugin instance.
|
||||
* @param string $plugin_id
|
||||
* The plugin ID for the plugin instance.
|
||||
* @param mixed $plugin_definition
|
||||
* The plugin implementation definition.
|
||||
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
|
||||
* The entity repository.
|
||||
* @param \Drupal\Core\Image\ImageFactory $image_factory
|
||||
* The image factory.
|
||||
*/
|
||||
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityRepositoryInterface $entity_repository, ImageFactory $image_factory) {
|
||||
$this->entityRepository = $entity_repository;
|
||||
$this->imageFactory = $image_factory;
|
||||
parent::__construct($configuration, $plugin_id, $plugin_definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
|
||||
return new static(
|
||||
$configuration,
|
||||
$plugin_id,
|
||||
$plugin_definition,
|
||||
$container->get('entity.repository'),
|
||||
$container->get('image.factory')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function process($text, $langcode) {
|
||||
$result = new FilterProcessResult($text);
|
||||
|
||||
if (stristr($text, 'data-entity-type="file"') !== FALSE) {
|
||||
$dom = Html::load($text);
|
||||
$xpath = new \DOMXPath($dom);
|
||||
$processed_uuids = [];
|
||||
foreach ($xpath->query('//*[@data-entity-type="file" and @data-entity-uuid]') as $node) {
|
||||
$uuid = $node->getAttribute('data-entity-uuid');
|
||||
|
||||
// If there is a 'src' attribute, set it to the file entity's current
|
||||
// URL. This ensures the URL works even after the file location changes.
|
||||
if ($node->hasAttribute('src')) {
|
||||
$file = $this->entityRepository->loadEntityByUuid('file', $uuid);
|
||||
if ($file instanceof FileInterface) {
|
||||
$node->setAttribute('src', $file->createFileUrl());
|
||||
if ($node->nodeName == 'img') {
|
||||
$image = $this->imageFactory->get($file->getFileUri());
|
||||
$width = $image->getWidth();
|
||||
$height = $image->getHeight();
|
||||
// Set dimensions to avoid content layout shift (CLS).
|
||||
// @see https://web.dev/cls/
|
||||
if ($width !== NULL && !$node->hasAttribute('width')) {
|
||||
$node->setAttribute('width', (string) $width);
|
||||
}
|
||||
if ($height !== NULL && !$node->hasAttribute('height')) {
|
||||
$node->setAttribute('height', (string) $height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only process the first occurrence of each file UUID.
|
||||
if (!isset($processed_uuids[$uuid])) {
|
||||
$processed_uuids[$uuid] = TRUE;
|
||||
|
||||
$file = $this->entityRepository->loadEntityByUuid('file', $uuid);
|
||||
if ($file instanceof FileInterface) {
|
||||
$result->addCacheTags($file->getCacheTags());
|
||||
}
|
||||
}
|
||||
}
|
||||
$result->setProcessedText(Html::serialize($dom));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user