first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,290 @@
<?php
/**
* @file
* Documentation related to CKEditor 5.
*/
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
/**
* @defgroup ckeditor5_architecture CKEditor 5 architecture
* @{
*
* @section overview Overview
* The CKEditor 5 module integrates CKEditor 5 with Drupal's filtering and text
* editor APIs.
*
* Where possible, it uses upstream CKEditor plugins, but it also relies on
* Drupal-specific CKEditor plugins to ensure a consistent user experience.
*
* @see https://ckeditor.com/ckeditor-5/
*
* @section data_models Data models
* Drupal and CKEditor 5 have very different data models.
*
* Drupal stores blobs of HTML that remains manageable thanks to the use of
* filters and granular HTML restrictions crucially this remains manageable
* thanks to those restrictions but also because Drupal does not need to
* process, render, understand or otherwise interact with it.
*
* @see \Drupal\text\Plugin\Field\FieldType\TextItemBase
* @see \Drupal\filter\Plugin\Filter\FilterInterface::getHTMLRestrictions()
*
* On the other hand, CKEditor 5 must not only be able to render these
* blobs, but also allow editing and creating it. This requires a much deeper
* understanding of that HTML.
*
* CKEditor 5 (in contrast with CKEditor 4) therefore has its own data model to
* represent this information that data model is explicitly not HTML.
*
* Therefore all interactions between Drupal and CKEditor 5 need to translate
* between these different data models.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#element-types-and-custom-data
*
* @section plugins CKEditor 5 Plugins
* CKEditor 5 plugins may use either YAML or a PHP attribute for their
* definitions. A PHP class does not need an attribute if it is defined in yml.
*
* To be discovered, YAML definition files must be named
* {module_name}.ckeditor5.yml.
*
* @see ckeditor5.ckeditor5.yml for many examples of CKEditor 5 plugin configuration as YAML.
*
* The minimally required metadata: the CKEditor 5 plugins to load, the label
* and the HTML elements it can generate here's an example for a module
* providing a Marquee plugin, both in yml or Annotation form:
*
* Declared in the yml file:
* @code
* # In the MODULE_NAME.ckeditor5.yml file.
*
* MODULE_NAME_marquee:
* ckeditor5:
* plugins: [PACKAGE.CLASS]
* drupal:
* label: Marquee
* library: MODULE_NAME/ckeditor5.marquee
* elements:
* - <marquee>
* - <marquee behavior>
* @endcode
*
* Declared as an Attribute:
* @code
* use Drupal\ckeditor5\Attribute\CKEditor5AspectsOfCKEditor5Plugin;
* use Drupal\ckeditor5\Attribute\CKEditor5Plugin;
* use Drupal\ckeditor5\Attribute\DrupalAspectsOfCKEditor5Plugin;
* use Drupal\Core\StringTranslation\TranslatableMarkup;
*
* #[CKEditor5Plugin(
* id: 'MODULE_NAME_marquee',
* ckeditor5: new CKEditor5AspectsOfCKEditor5Plugin(
* plugins: ['PACKAGE.CLASS'],
* ),
* drupal: new DrupalAspectsOfCKEditor5Plugin(
* label: new TranslatableMarkup('Marquee'),
* library: 'MODULE_NAME/ckeditor5.marquee',
* elements: ['<marquee>', '<marquee behavior>'],
* ),
* )]
* @endcode
*
* The metadata relating strictly to the CKEditor 5 plugin's JS code is stored
* in the 'ckeditor5' key; all other metadata is stored in the 'drupal' key.
*
* If the plugin has a dependency on another module, adding the 'provider' key
* will prevent the plugin from being loaded if that module is not installed.
*
* All of these can be defined in YAML or attributes. A given plugin should
* choose one or the other, as a definition can't parse both at once.
*
* Overview of all available plugin definition properties:
*
* - provider: Allows a plugin to have a dependency on another module. If it has
* a value, a module with a machine name matching that value must be installed
* for the configured plugin to load.
* - ckeditor5.plugins: A list CKEditor 5 JavaScript plugins to load, as
* '{package.Class}' , such as 'drupalMedia.DrupalMedia'.
* - ckeditor5.config: A keyed array of additional values for the constructor of
* the CKEditor 5 JavaScript plugins being loaded. i.e. this becomes the
* CKEditor 5 plugin configuration settings (see
* https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/configuration.html)
* for a given plugin.
* - drupal.label: Human-readable name of the CKEditor 5 plugin.
* - drupal.library: A Drupal asset library to load with the plugin.
* - drupal.admin_library: A Drupal asset library that will load in the text
* format admin UI when the plugin is available.
* - drupal.class: Optional PHP class that makes it possible for the plugin to
* provide dynamic values, or a configuration UI. The value should be
* formatted as '\Drupal\{module_name}\Plugin\CKEditor5Plugin\{class_name}' to
* make it discoverable.
* - drupal.elements: A list of elements and attributes the plugin allows use of
* within CKEditor 5. This uses the same syntax as the 'filter_html' plugin
* with an additional special keyword: '<$text-container>' . Using
* '<$text-container [attribute(s)]>` will permit the provided
* attributes in all CKEditor 5's `$block` text container tags that are
* explicitly enabled in any plugin. i.e. if only '<p>', '<h3>' and '<h2>'
* tags are allowed, then '<$text-container data-something>' will allow the
* 'data-something' attribute for '<p>', '<h3>' and '<h2>' tags.
* Note that while the syntax is the same, some extra nuance is needed:
* although this syntax can be used to create an attribute on an element, f.e.
* (['<marquee behavior>']) creating the `behavior` attribute on `<marquee>`,
* the tag itself must be creatable as well (['<marquee>']). If a plugin wants
* the tag and attribute to be created, list both:
* (['<marquee>', '<marquee behavior>']). Validation logic ensures that a
* plugin supporting only the creation of attributes cannot be enabled if the
* tag cannot be created via itself or through another CKEditor 5 plugin.
* - drupal.toolbar_items: List of toolbar items the plugin provides. Keyed by a
* machine name and the value being a pair defining the label:
* @code
* toolbar_items:
* indent:
* label: Indent
* outdent:
* label: Outdent
* @encode
* - drupal.conditions: Conditions required for the plugin to load (other than
* module dependencies, which are defined by the 'provider' property).
* Conditions can check for five different things:
* - 'toolbarItem': a toolbar item that must be enabled
* - 'filter': a filter that must be enabled
* - 'imageUploadStatus': TRUE if image upload must be enabled, FALSE if it
* must not be enabled
* - 'requiresConfiguration': a subset of the configuration for this plugin
* that must match (exactly)
* - 'plugins': a list of CKEditor 5 Drupal plugin IDs that must be enabled
* Plugins requiring more complex conditions, such as requiring multiple
* toolbar items or multiple filters, have not yet been identified. If this
* need arises, see
* https://www.drupal.org/docs/drupal-apis/ckeditor-5-api/overview#conditions.
*
* All of these can be defined in YAML or attributes. A given plugin should
* choose one or the other, as a definition can't parse both at once.
*
* If the CKEditor 5 plugin contains translation they can be automatically
* loaded by Drupal by adding the dependency to the core/ckeditor5.translations
* library to the CKEditor 5 plugin library definition:
*
* @code
* # In the MODULE_NAME.libraries.yml file.
*
* marquee:
* js:
* assets/ckeditor5/marquee/marquee.js: { minified: true }
* dependencies:
* - core/ckeditor5
* - core/ckeditor5.translations
* @endcode
*
* The translations for CKEditor 5 are located in a translations/ subdirectory,
* Drupal will load the corresponding translation when necessary, located in
* assets/ckeditor5/marquee/translations/* in this example.
*
*
* @see \Drupal\ckeditor5\Attribute\CKEditor5Plugin
* @see \Drupal\ckeditor5\Attribute\CKEditor5AspectsOfCKEditor5Plugin
* @see \Drupal\ckeditor5\Attribute\DrupalAspectsOfCKEditor5Plugin
*
* @section upgrade_path Upgrade path
*
* Modules can provide upgrade paths similar to the built-in upgrade path for
* Drupal core's CKEditor 4 to CKEditor 5, by providing a CKEditor4To5Upgrade
* plugin. This plugin type allows:
* - mapping a CKEditor 4 button to an equivalent CKEditor 5 toolbar item
* - mapping CKEditor 4 plugin settings to equivalent CKEditor 5 plugin
* configuration.
* The supported CKEditor 4 buttons and/or CKEditor 4 plugin settings must be
* specified in the annotation.
* See Drupal core's implementation for an example.
*
* @see \Drupal\ckeditor5\Annotation\CKEditor4To5Upgrade
* @see \Drupal\ckeditor5\Plugin\CKEditor4To5UpgradePluginInterface
* @see \Drupal\ckeditor5\Plugin\CKEditor4To5Upgrade\Core
*
* @section public_api Public API
*
* The CKEditor 5 module provides no public API, other than:
* - the attributes and interfaces mentioned above;
* - to help implement CKEditor 5 plugins:
* \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait and
* \Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
* - \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition, which is used to
* interact with plugin definitions in hook_ckeditor5_plugin_info_alter();
* - to help contributed modules write tests:
* \Drupal\Tests\ckeditor5\Kernel\CKEditor5ValidationTestTrait and
* \Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
* - to help contributed modules write configuration schemas for configurable
* plugins, the data types in config/schema/ckeditor5.data_types.yml are
* likely to be useful. They automatically get validation constraints applied;
* - to help contributed modules write validation constraints for configurable
* plugins, it is strongly recommended to subclass
* \Drupal\Tests\ckeditor5\Kernel\ValidatorsTest. For very complex validation
* constraints that need to access text editor and/or format, use
* \Drupal\ckeditor5\Plugin\Validation\Constraint\TextEditorObjectDependentValidatorTrait.
*
* @}
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Modify the list of available CKEditor 5 plugins.
*
* This hook may be used to modify plugin properties after they have been
* specified by other modules.
*
* @param array $plugin_definitions
* An array of all the existing plugin definitions, passed by reference.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager
*/
function hook_ckeditor5_plugin_info_alter(array &$plugin_definitions): void {
// Add a link decorator to the link plugin.
assert($plugin_definitions['ckeditor5_link'] instanceof CKEditor5PluginDefinition);
$link_plugin_definition = $plugin_definitions['ckeditor5_link']->toArray();
$link_plugin_definition['ckeditor5']['config']['link']['decorators'][] = [
'mode' => 'manual',
'label' => t('Open in new window'),
'attributes' => [
'target' => '_blank',
],
];
$plugin_definitions['ckeditor5_link'] = new CKEditor5PluginDefinition($link_plugin_definition);
// Add a custom file type to the image upload plugin. Note that 'tiff' below
// should be an IANA image media type Name, with the "image/" prefix omitted.
// In other words: a subtype of type image.
// @see https://www.iana.org/assignments/media-types/media-types.xhtml#image
// @see https://ckeditor.com/docs/ckeditor5/latest/api/module_image_imageconfig-ImageUploadConfig.html#member-types
assert($plugin_definitions['ckeditor5_imageUpload'] instanceof CKEditor5PluginDefinition);
$image_upload_plugin_definition = $plugin_definitions['ckeditor5_imageUpload']->toArray();
$image_upload_plugin_definition['ckeditor5']['config']['image']['upload']['types'][] = 'tiff';
$plugin_definitions['ckeditor5_imageUpload'] = new CKEditor5PluginDefinition($image_upload_plugin_definition);
}
/**
* Modify the list of available CKEditor 4 to 5 Upgrade plugins.
*
* This hook may be used to modify plugin properties after they have been
* specified by other modules. For example, to override a default upgrade path.
*
* @param array $plugin_definitions
* An array of all the existing plugin definitions, passed by reference.
*
* @see \Drupal\ckeditor5\Plugin\CKEditor4To5UpgradePluginManager
*/
function hook_ckeditor4to5upgrade_plugin_info_alter(array &$plugin_definitions): void {
// Remove core's upgrade path for the "Maximize" button (which is: there is no
// equivalent). This allows a different CKEditor4To5Upgrade plugin to define
// this upgrade path instead.
unset($plugin_definitions['core']['cke4_buttons']['Maximize']);
}
/**
* @} End of "addtogroup hooks".
*/

View File

@@ -0,0 +1,837 @@
# CKEditor 5 Drupal plugin definitions.
# @see this module's README.md for details on defining CKEditor 5 plugins in
# Drupal.
ckeditor5_essentials:
ckeditor5:
plugins:
- drupalHtmlEngine.DrupalHtmlEngine
- essentials.Essentials
drupal:
label: Essentials
library: ckeditor5/internal.drupal.ckeditor5.htmlEngine
admin_library: ckeditor5/internal.admin.essentials
toolbar_items:
undo:
label: Undo
redo:
label: Redo
elements:
- <br>
conditions: []
ckeditor5_paragraph:
ckeditor5:
plugins: [paragraph.Paragraph]
drupal:
label: Paragraph
library: core/ckeditor5.essentials
admin_library: ckeditor5/internal.admin.essentials
elements:
- <p>
ckeditor5_heading:
ckeditor5:
plugins: [heading.Heading]
config:
heading:
# These are the options passed to the CKEditor heading constructor
# @see https://ckeditor.com/docs/ckeditor5/latest/api/module_heading_heading-HeadingConfig.html#member-options
# for details on what each of these config properties do.
options:
- { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' }
- { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' }
- { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' }
- { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' }
- { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }
- { model: 'heading5', view: 'h5', title: 'Heading 5', class: 'ck-heading_heading5' }
- { model: 'heading6', view: 'h6', title: 'Heading 6', class: 'ck-heading_heading6' }
drupal:
label: Headings
library: core/ckeditor5.essentials
admin_library: ckeditor5/internal.admin.heading
class: Drupal\ckeditor5\Plugin\CKEditor5Plugin\Heading
toolbar_items:
heading:
label: Heading
elements:
- <h1>
- <h2>
- <h3>
- <h4>
- <h5>
- <h6>
ckeditor5_style:
ckeditor5:
plugins: [style.Style]
drupal:
label: Style
library: core/ckeditor5.style
admin_library: ckeditor5/internal.admin.style
class: Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style
toolbar_items:
style:
label: Style
# This plugin is able to add any configured class on any tag that can be
# created by some other CKEditor 5 plugin. Hence it indicates it allows all
# classes on all tags. Its subset then restricts this to a concrete set of
# tags, and a concrete set of classes.
# @todo Update in https://www.drupal.org/project/drupal/issues/3280124
# @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style::getElementsSubset()
# @see \Drupal\ckeditor5\Plugin\Validation\Constraint\StyleSensibleElementConstraintValidator
elements:
- <$any-html5-element class>
ckeditor5_htmlComments:
ckeditor5:
plugins:
- htmlSupport.HtmlComment
drupal:
label: HTML Comment support
elements: false
library: core/ckeditor5.htmlSupport
# @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface::getEnabledDefinitions()
conditions: []
ckeditor5_arbitraryHtmlSupport:
ckeditor5:
plugins: [htmlSupport.GeneralHtmlSupport]
config:
htmlSupport:
allow:
-
name:
regexp:
pattern: /.*/
attributes: true
classes: true
styles: true
drupal:
label: Arbitrary HTML support
elements: false
library: core/ckeditor5.htmlSupport
# @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface::getEnabledDefinitions()
conditions: []
ckeditor5_wildcardHtmlSupport:
ckeditor5:
plugins: [htmlSupport.GeneralHtmlSupport]
drupal:
label: Wildcard HTML support
# @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig()
elements: false
library: core/ckeditor5.htmlSupport
# @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface::getEnabledDefinitions()
conditions: []
# https://html.spec.whatwg.org/multipage/dom.html#attr-dir
ckeditor5_globalAttributeDir:
ckeditor5:
plugins: [htmlSupport.GeneralHtmlSupport]
config:
htmlSupport:
allow:
-
# @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\GlobalAttribute::getDynamicPluginConfig()
name: ~
attributes:
- key: dir
value:
regexp:
pattern: /^(ltr|rtl)$/
drupal:
label: Global `dir` attribute
class: \Drupal\ckeditor5\Plugin\CKEditor5Plugin\GlobalAttribute
# @see \Drupal\filter\Plugin\Filter\FilterHtml::getHTMLRestrictions()
elements:
- <* dir="ltr rtl">
library: core/ckeditor5.htmlSupport
conditions:
filter: filter_html
# https://html.spec.whatwg.org/multipage/dom.html#attr-lang
ckeditor5_globalAttributeLang:
ckeditor5:
plugins: [htmlSupport.GeneralHtmlSupport]
config:
htmlSupport:
allow:
-
# @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\GlobalAttribute::getDynamicPluginConfig()
name: ~
attributes: lang
drupal:
label: Global `lang` attribute
class: \Drupal\ckeditor5\Plugin\CKEditor5Plugin\GlobalAttribute
# @see \Drupal\filter\Plugin\Filter\FilterHtml::getHTMLRestrictions()
elements:
- <* lang>
library: core/ckeditor5.htmlSupport
conditions:
filter: filter_html
ckeditor5_specialCharacters:
ckeditor5:
plugins:
- specialCharacters.SpecialCharacters
- specialCharacters.SpecialCharactersEssentials
drupal:
label: Special characters
library: core/ckeditor5.specialCharacters
admin_library: ckeditor5/internal.admin.specialCharacters
toolbar_items:
specialCharacters:
label: Special characters
elements: false
ckeditor5_sourceEditing:
ckeditor5:
plugins:
- sourceEditing.SourceEditing
- htmlSupport.GeneralHtmlSupport
drupal:
label: Source editing
class: \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing
# This is the only CKEditor 5 plugin allowed to generate a superset of elements.
# @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing::getElementsSubset()
# @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::validateDrupalAspects()
# @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getProvidedElements()
elements: []
library: core/ckeditor5.sourceEditing
admin_library: ckeditor5/internal.admin.sourceEditing
toolbar_items:
sourceEditing:
label: Source
ckeditor5_bold:
ckeditor5:
plugins: [basicStyles.Bold]
drupal:
label: Bold
library: core/ckeditor5.basic
admin_library: ckeditor5/internal.admin.basic
toolbar_items:
bold:
label: Bold
elements:
- <strong>
ckeditor5_emphasis:
ckeditor5:
plugins:
- basicStyles.Italic
- drupalEmphasis.DrupalEmphasis
drupal:
label: Emphasis
library: ckeditor5/internal.drupal.ckeditor5.emphasis
admin_library: ckeditor5/internal.admin.basic
toolbar_items:
italic:
label: Italic
elements:
- <em>
ckeditor5_underline:
ckeditor5:
plugins: [basicStyles.Underline]
drupal:
label: Underline
library: core/ckeditor5.basic
admin_library: ckeditor5/internal.admin.basic
toolbar_items:
underline:
label: Underline
elements:
- <u>
ckeditor5_code:
ckeditor5:
plugins: [basicStyles.Code]
drupal:
label: Code
library: core/ckeditor5.basic
admin_library: ckeditor5/internal.admin.basic
toolbar_items:
code:
label: Code
elements:
- <code>
ckeditor5_codeBlock:
ckeditor5:
plugins:
- codeBlock.CodeBlock
- htmlSupport.GeneralHtmlSupport
config:
# The CodeBlock plugin supports only `<pre><code>…</code></pre>`.
# Configure GHS to support `<pre>…</pre>` markup as well.
htmlSupport:
allow:
-
name: pre
drupal:
label: Code Block
library: ckeditor5/internal.drupal.ckeditor5.codeBlock
admin_library: ckeditor5/internal.admin.codeBlock
class: Drupal\ckeditor5\Plugin\CKEditor5Plugin\CodeBlock
toolbar_items:
codeBlock:
label: Code Block
elements:
- <pre>
- <code>
- <code class="language-*">
ckeditor5_strikethrough:
ckeditor5:
plugins: [basicStyles.Strikethrough]
drupal:
label: Strikethrough
library: core/ckeditor5.basic
admin_library: ckeditor5/internal.admin.basic
toolbar_items:
strikethrough:
label: Strikethrough
elements:
- <s>
ckeditor5_subscript:
ckeditor5:
plugins: [basicStyles.Subscript]
drupal:
label: Subscript
library: core/ckeditor5.basic
admin_library: ckeditor5/internal.admin.basic
toolbar_items:
subscript:
label: Subscript
elements:
- <sub>
ckeditor5_superscript:
ckeditor5:
plugins: [basicStyles.Superscript]
drupal:
label: Superscript
library: core/ckeditor5.basic
admin_library: ckeditor5/internal.admin.basic
toolbar_items:
superscript:
label: Superscript
elements:
- <sup>
ckeditor5_blockquote:
ckeditor5:
plugins:
- blockQuote.BlockQuote
drupal:
label: Block quote
library: core/ckeditor5.blockquote
admin_library: ckeditor5/internal.admin.blockquote
toolbar_items:
blockQuote:
label: Block quote
elements:
- <blockquote>
ckeditor5_link:
ckeditor5:
plugins:
- link.Link
config:
link:
# @see https://ckeditor.com/docs/ckeditor5/latest/features/link.html#adding-default-link-protocol-to-external-links
defaultProtocol: 'https://'
drupal:
label: Link
library: core/ckeditor5.link
admin_library: ckeditor5/internal.admin.link
toolbar_items:
link:
label: Link
elements:
- <a>
- <a href>
ckeditor5_linkImage:
ckeditor5:
plugins:
- link.LinkImage
config:
# Append the "Link" button to the image balloon toolbar.
image:
toolbar:
- '|'
- linkImage
drupal:
label: Linked Image
elements: false
conditions:
plugins:
- ckeditor5_link
- ckeditor5_image
ckeditor5_linkMedia:
ckeditor5:
plugins:
- drupalMedia.DrupalLinkMedia
config:
# Append the "Link" button to the media balloon toolbar.
drupalMedia:
toolbar: [drupalLinkMedia]
drupal:
label: Linked Media
elements: false
conditions:
plugins:
- ckeditor5_link
- media_media
ckeditor5_list:
ckeditor5:
plugins:
- list.List
- list.ListProperties
config:
list:
properties:
# @todo Make this configurable in https://www.drupal.org/project/drupal/issues/3274635
styles: false
drupal:
label: List
library: core/ckeditor5.list
admin_library: ckeditor5/internal.admin.list
class: Drupal\ckeditor5\Plugin\CKEditor5Plugin\ListPlugin
toolbar_items:
bulletedList:
label: Bulleted list
numberedList:
label: Numbered list
elements:
- <ul>
- <ol>
- <ol reversed start>
- <li>
ckeditor5_horizontalLine:
ckeditor5:
plugins: [horizontalLine.HorizontalLine]
drupal:
label: Horizontal line
library: core/ckeditor5.horizontalLine
admin_library: ckeditor5/internal.admin.horizontalLine
toolbar_items:
horizontalLine:
label: Horizontal line
elements:
- <hr>
ckeditor5_alignment:
ckeditor5:
plugins: [alignment.Alignment]
config:
# @see core/modules/system/css/components/align.module.css
alignment:
options:
- name: left
className: text-align-left
- name: center
className: text-align-center
- name: right
className: text-align-right
- name: justify
className: text-align-justify
drupal:
label: Alignment
library: core/ckeditor5.alignment
admin_library: ckeditor5/internal.admin.alignment
class: Drupal\ckeditor5\Plugin\CKEditor5Plugin\Alignment
toolbar_items:
alignment:
label: Text alignment
elements:
- <$text-container class="text-align-left text-align-center text-align-right text-align-justify">
ckeditor5_autoformat:
ckeditor5:
plugins:
- autoformat.Autoformat
drupal:
label: Autoformat
library: core/ckeditor5.autoformat
elements: false
ckeditor5_removeFormat:
ckeditor5:
plugins: [removeFormat.RemoveFormat]
drupal:
label: Remove Format
library: core/ckeditor5.removeFormat
admin_library: ckeditor5/internal.admin.removeFormat
toolbar_items:
removeFormat:
label: Remove Format
elements: false
ckeditor5_pasteFromOffice:
ckeditor5:
plugins: [pasteFromOffice.PasteFromOffice]
drupal:
label: Paste From Office
library: core/ckeditor5.pasteFromOffice
elements: false
conditions: []
ckeditor5_table:
ckeditor5:
plugins:
- table.Table
- table.TableToolbar
- table.TableCaption
- table.PlainTableOutput
config:
table:
contentToolbar: [tableColumn, tableRow, mergeTableCells, toggleTableCaption]
drupal:
label: Table
library: core/ckeditor5.table
admin_library: ckeditor5/internal.admin.table
toolbar_items:
insertTable:
label: table
elements:
- <table>
- <tr>
- <td>
- <td rowspan colspan>
- <th>
- <th rowspan colspan>
- <thead>
- <tbody>
- <tfoot>
- <caption>
ckeditor5_table_properties:
ckeditor5:
plugins:
- table.TableProperties
config:
table:
contentToolbar: [tableProperties]
drupal:
label: Table properties
library: ckeditor5/internal.drupal.ckeditor5.table
conditions:
plugins:
- ckeditor5_table
# When arbitrary HTML is already allowed, it's harmless to enable CKEditor 5's UI for table properties.
- ckeditor5_arbitraryHtmlSupport
elements:
- <table style>
ckeditor5_table_cell_properties:
ckeditor5:
plugins:
- table.TableCellProperties
config:
table:
contentToolbar: [tableCellProperties]
drupal:
label: Table cell properties
library: ckeditor5/internal.drupal.ckeditor5.table
conditions:
plugins:
- ckeditor5_table
# When arbitrary HTML is already allowed, it's harmless to enable CKEditor 5's UI for table cell properties.
- ckeditor5_arbitraryHtmlSupport
elements:
- <td style>
- <td rowspan colspan style>
- <th style>
- <th rowspan colspan style>
ckeditor5_image:
ckeditor5:
plugins:
- image.Image
- image.ImageToolbar
- drupalImage.DrupalImage
- drupalImage.DrupalInsertImage
config:
image:
toolbar: [drupalImageAlternativeText]
insert:
# Determine image type (inline or block) by insertion context.
type: auto
integrations: []
drupal:
label: Image
class: \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
library: ckeditor5/internal.drupal.ckeditor5.image
admin_library: ckeditor5/internal.admin.image
elements:
- <img>
- <img src alt height width>
toolbar_items:
drupalInsertImage:
label: Image
conditions:
toolbarItem: drupalInsertImage
ckeditor5_imageUpload:
ckeditor5:
plugins:
- image.ImageUpload
- drupalImage.DrupalImageUpload
config:
image:
upload:
# The strings in this array should be IANA media type Names, without the "image/" prefix. So: image subtypes.
# https://www.iana.org/assignments/media-types/media-types.xhtml#image
types: ["jpeg", "png", "gif"]
drupal:
label: Image Upload
elements:
- <img data-entity-uuid data-entity-type>
conditions:
imageUploadStatus: true
plugins: [ckeditor5_image]
ckeditor5_imageUrl:
ckeditor5:
plugins:
- image.ImageInsertViaUrl
drupal:
label: Image URL
elements: false
conditions:
imageUploadStatus: false
plugins: [ckeditor5_image]
ckeditor5_imageResize:
ckeditor5:
plugins:
- image.ImageResize
config:
image:
resizeUnit: 'px'
resizeOptions:
-
name: 'resizeImage:original'
value: null
toolbar: [resizeImage]
drupal:
label: Image resize
class: \Drupal\ckeditor5\Plugin\CKEditor5Plugin\ImageResize
elements: false
conditions:
requiresConfiguration:
allow_resize: true
plugins: [ckeditor5_image]
ckeditor5_imageCaption:
ckeditor5:
plugins:
- image.ImageCaption
config:
image:
toolbar: [toggleImageCaption]
drupal:
label: Image caption
elements:
- <img data-caption>
conditions:
filter: filter_caption
plugins: [ckeditor5_image]
ckeditor5_imageAlign:
ckeditor5:
plugins:
- image.ImageStyle
config:
image:
toolbar:
- '|'
- 'imageStyle:block'
- 'imageStyle:alignLeft'
- 'imageStyle:alignCenter'
- 'imageStyle:alignRight'
- 'imageStyle:inline'
- '|'
styles:
options:
- inline
- name: 'block'
icon: 'left'
title: 'Break text'
- name: 'alignLeft'
title: 'Align left and wrap text'
- name: 'alignCenter'
title: 'Align center and break text'
- name: 'alignRight'
title: 'Align right and wrap text'
drupal:
label: Image align
elements:
- <img data-align>
conditions:
filter: filter_align
plugins: [ckeditor5_image]
ckeditor5_indent:
ckeditor5:
plugins: [indent.Indent]
drupal:
label: Indent
elements: false
library: core/ckeditor5.indent
admin_library: ckeditor5/internal.admin.indent
toolbar_items:
indent:
label: Indent
outdent:
label: Outdent
ckeditor5_language:
ckeditor5:
plugins: [language.TextPartLanguage]
drupal:
label: Language
library: ckeditor5/internal.ckeditor5.language
admin_library: ckeditor5/internal.admin.language
class: Drupal\ckeditor5\Plugin\CKEditor5Plugin\Language
toolbar_items:
textPartLanguage:
label: Language
elements:
- <span lang dir>
ckeditor5_showBlocks:
ckeditor5:
plugins: [showBlocks.ShowBlocks]
drupal:
label: Show blocks
library: core/ckeditor5.showBlocks
admin_library: ckeditor5/internal.admin.showBlocks
toolbar_items:
showBlocks:
label: Show blocks
elements: false
media_media:
provider: media
ckeditor5:
plugins:
- drupalMedia.DrupalMedia
- drupalMedia.DrupalElementStyle
config:
drupalMedia:
toolbar: [mediaImageTextAlternative]
themeError:
func:
name: Drupal.theme
args: [mediaEmbedPreviewError]
invoke: true
drupal:
label: Media
library: ckeditor5/internal.drupal.ckeditor5.media
class: Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media
elements:
- <drupal-media>
- <drupal-media data-entity-type data-entity-uuid alt>
- <drupal-media data-view-mode>
conditions:
filter: media_embed
ckeditor5_drupalMediaCaption:
ckeditor5:
plugins:
- drupalMedia.DrupalMediaCaption
config:
drupalMedia:
toolbar: [toggleDrupalMediaCaption]
drupal:
label: Media caption
elements:
- <drupal-media data-caption>
conditions:
filter: filter_caption
plugins:
- media_media
media_mediaAlign:
provider: media
ckeditor5:
plugins:
- drupalMedia.DrupalElementStyle
config:
drupalElementStyles:
align:
- name: 'right'
title: 'Align right and wrap text'
icon: 'objectRight'
attributeName: 'data-align'
attributeValue: 'right'
modelElements: [ 'drupalMedia' ]
- name: 'left'
title: 'Align left and wrap text'
icon: 'objectLeft'
attributeName: 'data-align'
attributeValue: 'left'
modelElements: [ 'drupalMedia' ]
- name: 'center'
title: 'Align center and break text'
icon: 'objectCenter'
attributeName: 'data-align'
attributeValue: 'center'
modelElements: ['drupalMedia']
- name: 'breakText'
title: 'Break text'
icon: 'objectBlockLeft'
isDefault: true
modelElements: [ 'drupalMedia' ]
drupalMedia:
toolbar:
- '|'
- 'drupalElementStyle:align:breakText'
- 'drupalElementStyle:align:left'
- 'drupalElementStyle:align:center'
- 'drupalElementStyle:align:right'
- '|'
drupal:
label: Media align
library: ckeditor5/internal.drupal.ckeditor5.mediaAlign
elements:
- <drupal-media data-align>
conditions:
filter: filter_align
plugins: [media_media]
media_library_mediaLibrary:
provider: media_library
ckeditor5:
plugins: []
config:
drupalMedia:
openDialog:
func:
name: Drupal.ckeditor5.openDialog
invoke: false
dialogSettings:
classes:
ui-dialog: media-library-widget-modal
height: 75%
drupal:
label: Media Library
elements: false
admin_library: ckeditor5/internal.admin.drupalmedia
class: Drupal\ckeditor5\Plugin\CKEditor5Plugin\MediaLibrary
library: editor/drupal.editor.dialog
toolbar_items:
drupalMedia:
label: Drupal media
conditions:
filter: media_embed
toolbarItem: drupalMedia

View File

@@ -0,0 +1,12 @@
name: CKEditor 5
type: module
description: "Provides the CKEditor 5 rich text editor."
# version: VERSION
package: Core
dependencies:
- drupal:editor
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,229 @@
# Internal libraries, do not depend on these.
# CKEditor 5 has a much faster release cadence for major and minor releases
# than Drupal. CKEditor 5 does not provide continued support for major or
# minor releases; they almost never issue patch releases. Drupal therefore
# has to keep its integration up-to-date with upstream. It is hence
# impossible to provide "stable overrides", since the stability is not
# controlled by Drupal, but by upstream.
# Hence all CKEditor 5 asset libraries are considered internal.
# @see https://ckeditor.com/docs/ckeditor5/latest/support/versioning-policy.html
internal.ckeditor5.language:
css:
component:
css/language.css: {}
dependencies:
- core/ckeditor5.language
internal.drupal.ckeditor5.htmlEngine:
js:
js/build/drupalHtmlEngine.js: { minified: true }
dependencies:
- core/ckeditor5
- core/ckeditor5.essentials
internal.drupal.ckeditor5:
js:
js/ckeditor5.js: {}
css:
theme:
css/editor.css: { }
dependencies:
- core/jquery
- core/once
- core/drupal
- core/drupal.ajax
- core/drupal.debounce
- core/ckeditor5.editorClassic
- core/ckeditor5.editorDecoupled
- core/ckeditor5
- editor/drupal.editor
- ckeditor5/internal.drupal.ckeditor5.stylesheets
- core/drupalSettings
- core/drupal.message
# Library used for dynamically loading CKEditor 5 stylesheets from the default
# front end theme.
# @see ckeditor5_library_info_alter()
internal.drupal.ckeditor5.stylesheets:
version: VERSION
css: []
internal.drupal.ckeditor5.codeBlock:
dependencies:
- core/ckeditor5.codeBlock
- core/ckeditor5.htmlSupport
internal.drupal.ckeditor5.image:
js:
js/build/drupalImage.js: { minified: true }
css:
theme:
css/image.css: { }
dependencies:
- core/drupal
- core/ckeditor5
- core/ckeditor5.image
internal.drupal.ckeditor5.emphasis:
version: VERSION
js:
js/build/drupalEmphasis.js: { minified: true }
dependencies:
- core/ckeditor5
- core/ckeditor5.basic
internal.drupal.ckeditor5.media:
js:
js/build/drupalMedia.js: { minified: true }
css:
theme:
css/drupalmedia.css: { }
dependencies:
- core/ckeditor5
- core/drupal
- media/media_embed_ckeditor_theme
internal.drupal.ckeditor5.mediaAlign:
css:
theme:
css/media-alignment.css: { }
dependencies:
- ckeditor5/internal.drupal.ckeditor5.media
internal.drupal.ckeditor5.filter.admin:
js:
js/ckeditor5.filter.admin.js: {}
dependencies:
- core/drupal
- core/once
- core/drupal.ajax
- core/drupalSettings
internal.drupal.ckeditor5.table:
css:
theme:
css/table.css: { }
dependencies:
- core/ckeditor5.table
internal.admin:
js:
js/ckeditor5.admin.js: { }
css:
theme:
css/toolbar.admin.css: { }
dependencies:
- core/sortable
- filter/drupal.filter.admin
- core/jquery
- core/once
- core/drupal.announce
internal.admin.specialCharacters:
css:
theme:
css/special-characters.css: { }
internal.admin.removeFormat:
css:
theme:
css/remove-format.css: { }
internal.admin.essentials:
css:
theme:
css/essentials.admin.css: { }
internal.admin.basic:
css:
theme:
css/basic.admin.css: { }
internal.admin.blockquote:
css:
theme:
css/blockquote.admin.css: { }
internal.admin.link:
css:
theme:
css/link.admin.css: { }
internal.admin.list:
css:
theme:
css/list.admin.css: { }
internal.admin.heading:
css:
theme:
css/heading.admin.css: { }
dependencies:
- core/ckeditor5.essentials
internal.admin.horizontalLine:
css:
theme:
css/horizontal-line.admin.css: { }
internal.admin.alignment:
css:
theme:
css/alignment.admin.css: { }
internal.admin.indent:
css:
theme:
css/indent.admin.css: { }
internal.admin.language:
css:
theme:
css/language.admin.css: { }
internal.admin.drupalmedia:
css:
theme:
css/drupalmedia.admin.css: { }
internal.admin.showBlocks:
css:
theme:
css/show-blocks.admin.css: { }
internal.admin.sourceEditing:
css:
theme:
css/source-editing.admin.css: { }
internal.admin.style:
js:
js/ckeditor5.style.admin.js: { }
css:
theme:
css/style.admin.css: { }
dependencies:
- core/jquery
- core/drupal
- core/drupal.vertical-tabs
internal.admin.table:
css:
theme:
css/table.admin.css: { }
internal.admin.codeBlock:
css:
theme:
css/code-block.admin.css: { }
internal.admin.image:
css:
theme:
css/image.admin.css: { }
js:
js/ckeditor5.image.admin.js: { }
dependencies:
- core/jquery
- core/drupal

View File

@@ -0,0 +1,702 @@
<?php
/**
* @file
* Implements hooks for the CKEditor 5 module.
*/
declare(strict_types = 1);
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\Ajax\MessageCommand;
use Drupal\Core\Ajax\PrependCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Ajax\RemoveCommand;
use Drupal\Core\Asset\AttachedAssetsInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Drupal\editor\EditorInterface;
// cspell:ignore multiblock
/**
* Implements hook_help().
*/
function ckeditor5_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.ckeditor5':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t('The CKEditor 5 module provides a highly-accessible, highly-usable visual text editor and adds a toolbar to text fields. Users can use buttons to format content and to create semantically correct and valid HTML. The CKEditor module uses the framework provided by the <a href=":text_editor">Text Editor module</a>. It requires JavaScript to be enabled in the browser. For more information, see the <a href=":doc_url">online documentation for the CKEditor 5 module</a> and the <a href=":cke5_url">CKEditor 5 website</a>.', [':doc_url' => 'https://www.drupal.org/docs/contributed-modules/ckeditor-5', ':cke5_url' => 'https://ckeditor.com/ckeditor-5/', ':text_editor' => Url::fromRoute('help.page', ['name' => 'editor'])->toString()]) . '</p>';
$output .= '<h2>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Enabling CKEditor 5 for individual text formats') . '</dt>';
$output .= '<dd>' . t('CKEditor 5 has to be installed and configured separately for individual text formats from the <a href=":formats">Text formats and editors page</a> because the filter settings for each text format can be different. For more information, see the <a href=":text_editor">Text Editor help page</a> and <a href=":filter">Filter help page</a>.', [':formats' => Url::fromRoute('filter.admin_overview')->toString(), ':text_editor' => Url::fromRoute('help.page', ['name' => 'editor'])->toString(), ':filter' => Url::fromRoute('help.page', ['name' => 'filter'])->toString()]) . '</dd>';
$output .= '<dt>' . t('Configuring the toolbar') . '</dt>';
$output .= '<dd>' . t('When CKEditor 5 is chosen from the <em>Text editor</em> drop-down menu, its toolbar configuration is displayed. You can add and remove buttons from the <em>Active toolbar</em> by dragging and dropping them. Separators and rows can be added to organize the buttons.') . '</dd>';
$output .= '<dt>' . t('Filtering HTML content') . '</dt>';
$output .= '<dd>' . t("Unlike other text editors, plugin configuration determines the tags and attributes allowed in text formats using CKEditor 5. If using the <em>Limit allowed HTML tags and correct faulty HTML</em> filter, this filter's values will be automatically set based on enabled plugins and toolbar items.");
$output .= '<dt>' . t('Toggling between formatted text and HTML source') . '</dt>';
$output .= '<dd>' . t('If the <em>Source</em> button is available in the toolbar, users can click this button to disable the visual editor and edit the HTML source directly. After toggling back, the visual editor uses the HTML tags allowed via plugin configuration (and not explicity disallowed by filters) to format the text. Tags not enabled via plugin configuration will be stripped out of the HTML source when the user toggles back to the text editor.') . '</dd>';
$output .= '<dt>' . t('Developing CKEditor 5 plugins in Drupal') . '</dt>';
$output .= '<dd>' . t('See the <a href=":dev_docs_url">online documentation</a> for detailed information on developing CKEditor 5 plugins for use in Drupal.', [':dev_docs_url' => 'https://www.drupal.org/docs/contributed-modules/ckeditor-5/plugin-and-contrib-module-development']) . '</dd>';
$output .= '</dd>';
$output .= '<dt>' . t('Accessibility features') . '</dt>';
$output .= '<dd>' . t('The built in WYSIWYG editor (CKEditor 5) comes with a number of accessibility features. CKEditor 5 comes with built in <a href=":shortcuts">keyboard shortcuts</a>, which can be beneficial for both power users and keyboard only users.', [':shortcuts' => 'https://ckeditor.com/docs/ckeditor5/latest/features/keyboard-support.html']) . '</dd>';
$output .= '<dt>' . t('Generating accessible content') . '</dt>';
$output .= '<dd>';
$output .= '<ul>';
$output .= '<li>' . t('HTML tables can be created with table headers and caption/summary elements.') . '</li>';
$output .= '<li>' . t('Alt text is required by default on images added through CKEditor (note that this can be overridden).') . '</li>';
$output .= '<li>' . t('Semantic HTML5 figure/figcaption are available to add captions to images.') . '</li>';
$output .= '<li>' . t('To support multilingual page content, CKEditor 5 can be configured to include a language button in the toolbar.') . '</li>';
$output .= '</ul>';
$output .= '</dd>';
$output .= '</dl>';
$output .= '<h3 id="migration-settings">' . t('Migrating an Existing Text Format to CKEditor 5') . '</h2>';
$output .= '<p>' . t('When switching an existing text format to use CKEditor 5, an automatic process is initiated that helps text formats switching to CKEditor 5 from CKEditor 4 (or no text editor) to do so with minimal effort and zero data loss.') . '</p>';
$output .= '<p>' . t("This process is designed for there to be no data loss risk in switching to CKEditor 5. However some of your editor's functionality may not be 100% equivalent to what was available previously. In most cases, these changes are minimal. After the process completes, status and/or warning messages will summarize any changes that occurred, and more detailed information will be available in the site's logs.") . '</p>';
$output .= '<p>' . t('CKEditor 5 will attempt to enable plugins that provide equivalent toolbar items to those used prior to switching to CKEditor 5. All core CKEditor 4 plugins and many popular contrib plugins already have CKEditor 5 equivalents. In some cases, functionality that required contrib modules is now built into CKEditor 5. In instances where a plugin does not have an equivalent, no data loss will occur but elements previously provided via the plugin may need to be added manually as HTML via source editing.') . '</p>';
$output .= '<h4>' . t('Additional migration considerations for text formats with restricted HTML') . '</h4>';
$output .= '<dl>';
$output .= '<dt>' . t('The “Allowed HTML tags" field in the “Limit allowed HTML tags and correct Faulty HTML" filter is now read-only') . '</dt>';
$output .= '<dd>' . t('This field accurately represents the tags/attributes allowed by a text format, but the allowed tags are based on which plugins are enabled and how they are configured. For example, enabling the Underline plugin adds the &lt;u&gt; tag to “Allowed HTML tags".') . '</dd>';
$output .= '<dt id="required-tags">' . t('The &lt;p&gt; and &lt;br &gt; tags will be automatically added to your text format.') . '</dt>';
$output .= '<dd>' . t('CKEditor 5 requires the &lt;p&gt; and &lt;br &gt; tags to achieve basic functionality. They will be automatically added to “Allowed HTML tags" on formats that previously did not allow them.') . '</dd>';
$output .= '<dt id="source-editing">' . t('Tags/attributes that are not explicitly supported by any plugin are supported by Source Editing') . '</dt>';
$output .= '<dd>' . t('When a necessary tag/attribute is not directly supported by an available plugin, the "Source Editing" plugin is enabled. This plugin is typically used for by passing the CKEditor 5 UI and editing contents as HTML source. In the settings for Source Editing, tags/attributes that aren\'t available via other plugins are added to Source Editing\'s "Manually editable HTML tags" setting so they are supported by the text format.') . '</dd>';
$output .= '</dl>';
return $output;
}
}
/**
* Implements hook_theme().
*/
function ckeditor5_theme() {
return [
// The theme hook is used for rendering the CKEditor 5 toolbar settings in
// the Drupal admin UI. The toolbar settings UI is internal, and utilizing
// it outside of core usages is not supported because the UI can change at
// any point.
// @internal
'ckeditor5_settings_toolbar' => [
'render element' => 'form',
],
];
}
/**
* Implements hook_module_implements_alter().
*/
function ckeditor5_module_implements_alter(&$implementations, $hook) {
// This module's implementation of form_filter_format_form_alter() must happen
// after the editor module's implementation, as that implementation adds the
// active editor to $form_state. It must also happen after the media module's
// implementation so media_filter_format_edit_form_validate can be removed
// from the validation chain, as that validator is not needed with CKEditor 5
// and will trigger a false error.
if ($hook === 'form_alter' && isset($implementations['ckeditor5']) && isset($implementations['editor'])) {
$group = $implementations['ckeditor5'];
unset($implementations['ckeditor5']);
$offset = array_search('editor', array_keys($implementations)) + 1;
if (array_key_exists('media', $implementations)) {
$media_offset = array_search('media', array_keys($implementations)) + 1;
$offset = max([$offset, $media_offset]);
}
$implementations = array_slice($implementations, 0, $offset, TRUE) +
['ckeditor5' => $group] +
array_slice($implementations, $offset, NULL, TRUE);
}
}
/**
* Implements hook_form_FORM_ID_alter().
*/
function ckeditor5_form_filter_format_form_alter(array &$form, FormStateInterface $form_state, $form_id) {
$editor = $form_state->get('editor');
// CKEditor 5 plugin config determines the available HTML tags. If an HTML
// restricting filter is enabled and the editor is CKEditor 5, the 'Allowed
// HTML tags' field is made read only and automatically populated with the
// values needed by CKEditor 5 plugins.
// @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::buildConfigurationForm()
if ($editor && $editor->getEditor() === 'ckeditor5') {
if (isset($form['filters']['settings']['filter_html']['allowed_html'])) {
$filter_allowed_html = &$form['filters']['settings']['filter_html']['allowed_html'];
$filter_allowed_html['#value_callback'] = [CKEditor5::class, 'getGeneratedAllowedHtmlValue'];
// Set readonly and add the form-disabled wrapper class as using #disabled
// or the disabled attribute will prevent the new values from being
// validated.
$filter_allowed_html['#attributes']['readonly'] = TRUE;
$filter_allowed_html['#wrapper_attributes']['class'][] = 'form-disabled';
$filter_allowed_html['#description'] = t('With CKEditor 5 this is a
read-only field. The allowed HTML tags and attributes are determined
by the CKEditor 5 configuration. Manually removing tags would break
enabled functionality, and any manually added tags would be removed by
CKEditor 5 on render.');
// The media_filter_format_edit_form_validate validator is not needed
// with CKEditor 5 as it exists to enforce the inclusion of specific
// allowed tags that are added automatically by CKEditor 5. The
// validator is removed so it does not conflict with the automatic
// addition of those allowed tags.
$key = array_search('media_filter_format_edit_form_validate', $form['#validate']);
if ($key !== FALSE) {
unset($form['#validate'][$key]);
}
}
}
// Override the AJAX callbacks for changing editors, so multiple areas of the
// form can be updated on change.
$form['editor']['editor']['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'trigger_as' => ['name' => 'editor_configure'],
];
$form['editor']['configure']['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
];
$form['editor']['settings']['subform']['toolbar']['items']['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'trigger_as' => ['name' => 'editor_configure'],
'event' => 'change',
'ckeditor5_only' => 'true',
];
foreach (Element::children($form['filters']['status']) as $filter_type) {
$form['filters']['status'][$filter_type]['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'trigger_as' => ['name' => 'editor_configure'],
'event' => 'change',
'ckeditor5_only' => 'true',
];
}
/**
* Recursively adds AJAX listeners to plugin settings elements.
*
* These are added so allowed tags and other fields that have values
* dependent on plugin settings can be updated via AJAX when these settings
* are changed in the editor form.
*
* @param array $plugins_config_form
* The plugins config subform render array.
*/
$add_listener = function (array &$plugins_config_form) use (&$add_listener): void {
$field_types = [
'checkbox',
'select',
'radios',
'textarea',
];
if (isset($plugins_config_form['#type']) && in_array($plugins_config_form['#type'], $field_types) && !isset($plugins_config_form['#ajax'])) {
$plugins_config_form['#ajax'] = [
'callback' => '_update_ckeditor5_html_filter',
'trigger_as' => ['name' => 'editor_configure'],
'event' => 'change',
'ckeditor5_only' => 'true',
];
}
foreach ($plugins_config_form as $key => &$value) {
if (is_array($value) && !str_contains((string) $key, '#')) {
$add_listener($value);
}
}
};
if (isset($form['editor']['settings']['subform']['plugins'])) {
$add_listener($form['editor']['settings']['subform']['plugins']);
}
// Add an ID to the filter settings vertical tabs wrapper to facilitate AJAX
// updates.
$form['filter_settings']['#wrapper_attributes']['id'] = 'filter-settings-wrapper';
$form['#after_build'][] = [CKEditor5::class, 'assessActiveTextEditorAfterBuild'];
$form['#validate'][] = [CKEditor5::class, 'validateSwitchingToCKEditor5'];
array_unshift($form['actions']['submit']['#submit'], 'ckeditor5_filter_format_edit_form_submit');
}
/**
* Form submission handler for filter format forms.
*/
function ckeditor5_filter_format_edit_form_submit(array $form, FormStateInterface $form_state) {
$limit_allowed_html_tags = isset($form['filters']['settings']['filter_html']['allowed_html']);
$manually_editable_tags = $form_state->getValue(['editor', 'settings', 'plugins', 'ckeditor5_sourceEditing', 'allowed_tags']);
$styles = $form_state->getValue(['editor', 'settings', 'plugins', 'ckeditor5_style', 'styles']);
if ($limit_allowed_html_tags && is_array($manually_editable_tags) || is_array($styles)) {
// When "Manually editable tags", "Style" and "limit allowed HTML tags" are
// all configured, the latter is dependent on the others. This dependent
// value is typically updated via AJAX, but it's possible for "Manually
// editable tags" to update without triggering the AJAX rebuild. That value
// is recalculated here on save to ensure it happens even if the AJAX
// rebuild doesn't happen.
$manually_editable_tags_restrictions = HTMLRestrictions::fromString(implode($manually_editable_tags ?? []));
$styles_restrictions = HTMLRestrictions::fromString(implode($styles ? array_column($styles, 'element') : []));
$format = $form_state->get('ckeditor5_validated_pair')->getFilterFormat();
$allowed_html = HTMLRestrictions::fromTextFormat($format);
$combined_tags_string = $allowed_html
->merge($manually_editable_tags_restrictions)
->merge($styles_restrictions)
->toFilterHtmlAllowedTagsString();
$form_state->setValue(['filters', 'filter_html', 'settings', 'allowed_html'], $combined_tags_string);
}
}
/**
* AJAX callback handler for filter_format_form().
*
* Used instead of editor_form_filter_admin_form_ajax from the editor module.
*/
function _update_ckeditor5_html_filter(array $form, FormStateInterface $form_state) {
$response = new AjaxResponse();
$renderer = \Drupal::service('renderer');
// Replace the editor settings with the settings for the currently selected
// editor. This is the default behavior of editor.module. Except when using
// CKEditor 5: then we only want CKEditor 5's plugin settings to be updated:
// the client side-rendered admin UI would otherwise be dependent on network
// latency.
$renderedField = $renderer->render($form['editor']['settings']);
if ($form_state->get('ckeditor5_is_active') && $form_state->get('ckeditor5_is_selected')) {
$plugin_settings_markup = $form['editor']['settings']['subform']['plugin_settings']['#markup'];
// If no configurable plugins are enabled, render an empty container with
// the same ID instead. Otherwise it'll be impossible to render plugin
// settings vertical tabs in the correct location when such a plugin is
// enabled.
// @see \Drupal\Core\Render\Element\VerticalTabs::preRenderVerticalTabs
$markup = $plugin_settings_markup ?? [
'#type' => 'container',
'#attributes' => ['id' => 'plugin-settings-wrapper'],
];
$response->addCommand(new ReplaceCommand('#plugin-settings-wrapper', $markup));
}
else {
$response->addCommand(new ReplaceCommand('#editor-settings-wrapper', $renderedField));
}
if ($form_state->get('ckeditor5_is_active')) {
// Delete all existing validation messages, replace them with the current set.
$response->addCommand(new RemoveCommand('#ckeditor5-realtime-validation-messages-container > *'));
$messages = \Drupal::messenger()->deleteAll();
foreach ($messages as $type => $messages_by_type) {
foreach ($messages_by_type as $message) {
$response->addCommand(new MessageCommand($message, '#ckeditor5-realtime-validation-messages-container', ['type' => $type], FALSE));
}
}
}
else {
// If switching to CKEditor 5 triggers a validation error, the real-time
// validation messages container will not exist, because CKEditor 5's
// configuration form will not be rendered.
// In this case, render it into the (empty) editor settings wrapper. When
// the validation error is addressed, CKEditor 5's configuration form will
// get rendered and will overwrite those validation error messages.
$response->addCommand(new PrependCommand('#editor-settings-wrapper', ['#type' => 'status_messages']));
}
// Rebuild filter_settings form item when one of the following is true:
// - Switching to CKEditor 5 from another text editor, and the current
// configuration triggers no fundamental compatibility errors.
// - Switching from CKEditor 5 to a different editor.
// - The editor is not being switched, and is currently CKEditor 5.
if ($form_state->get('ckeditor5_is_active') || ($form_state->get('ckeditor5_is_selected') && !$form_state->getError($form['editor']['editor']))) {
// Replace the filter settings with the settings for the currently selected
// editor.
$renderedSettings = $renderer->render($form['filter_settings']);
$response->addCommand(new ReplaceCommand('#filter-settings-wrapper', $renderedSettings));
}
// If switching to CKEditor 5 from another editor and there are errors in that
// switch, add an error class and attribute to the editor select, otherwise
// remove.
$ckeditor5_selected_but_errors = !$form_state->get('ckeditor5_is_active') && $form_state->get('ckeditor5_is_selected') && !empty($form_state->getErrors());
$response->addCommand(new InvokeCommand('[data-drupal-selector="edit-editor-editor"]', $ckeditor5_selected_but_errors ? 'addClass' : 'removeClass', ['error']));
$response->addCommand(new InvokeCommand('[data-drupal-selector="edit-editor-editor"]', $ckeditor5_selected_but_errors ? 'attr' : 'removeAttr', ['data-error-switching-to-ckeditor5', TRUE]));
/**
* Recursively find #attach items in the form and add as attachments to the
* AJAX response.
*
* @param array $form
* A form array.
* @param \Drupal\Core\Ajax\AjaxResponse $response
* The AJAX response attachments will be added to.
*/
$attach = function (array $form, AjaxResponse &$response) use (&$attach): void {
foreach ($form as $key => $value) {
if ($key === "#attached") {
$response->addAttachments(array_diff_key($value, ['placeholders' => '']));
}
elseif (is_array($value) && !str_contains((string) $key, '#')) {
$attach($value, $response);
}
}
};
$attach($form, $response);
return $response;
}
/**
* Returns a list of language codes supported by CKEditor 5.
*
* @param string|bool $lang
* The Drupal langcode to match.
*
* @return array|mixed|string
* The associated CKEditor 5 langcode.
*/
function _ckeditor5_get_langcode_mapping($lang = FALSE) {
// Cache the file system based language list calculation because this would
// be expensive to calculate all the time. The cache is cleared on core
// upgrades which is the only situation the CKEditor file listing should
// change.
$langcode_cache = \Drupal::cache()->get('ckeditor5.langcodes');
if (!empty($langcode_cache)) {
$langcodes = $langcode_cache->data;
}
if (empty($langcodes)) {
$langcodes = [];
// Collect languages included with CKEditor 5 based on file listing.
$files = scandir('core/assets/vendor/ckeditor5/ckeditor5-dll/translations');
foreach ($files as $file) {
if (str_ends_with($file, '.js')) {
$langcode = basename($file, '.js');
$langcodes[$langcode] = $langcode;
}
}
\Drupal::cache()->set('ckeditor5.langcodes', $langcodes);
}
// Get language mapping if available to map to Drupal language codes.
// This is configurable in the user interface and not expensive to get, so
// we don't include it in the cached language list.
$language_mappings = \Drupal::moduleHandler()->moduleExists('language') ? language_get_browser_drupal_langcode_mappings() : [];
foreach ($langcodes as $langcode) {
// If this language code is available in a Drupal mapping, use that to
// compute a possibility for matching from the Drupal langcode to the
// CKEditor langcode.
// For instance, CKEditor uses the langcode 'no' for Norwegian, Drupal
// uses 'nb'. This would then remove the 'no' => 'no' mapping and
// replace it with 'nb' => 'no'. Now Drupal knows which CKEditor
// translation to load.
if (isset($language_mappings[$langcode]) && !isset($langcodes[$language_mappings[$langcode]])) {
$langcodes[$language_mappings[$langcode]] = $langcode;
unset($langcodes[$langcode]);
}
}
if ($lang) {
return $langcodes[$lang] ?? 'en';
}
return $langcodes;
}
/**
* Implements hook_library_info_alter().
*/
function ckeditor5_library_info_alter(&$libraries, $extension) {
if ($extension === 'filter') {
$libraries['drupal.filter.admin']['dependencies'][] = 'ckeditor5/internal.drupal.ckeditor5.filter.admin';
}
$moduleHandler = \Drupal::moduleHandler();
if ($extension === 'ckeditor5') {
// Add paths to stylesheets specified by a theme's ckeditor5-stylesheets
// config property.
$css = _ckeditor5_theme_css();
$libraries['internal.drupal.ckeditor5.stylesheets'] = [
'css' => [
'theme' => array_fill_keys(array_values($css), []),
],
];
}
if ($extension === 'core') {
// CSS rule to resolve the conflict with z-index between CKEditor 5 and jQuery UI.
$libraries['drupal.dialog']['css']['component']['modules/ckeditor5/css/ckeditor5.dialog.fix.css'] = [];
// Fix the CKEditor 5 focus management in dialogs. Modify the library
// declaration to ensure this file is always loaded after
// drupal.dialog.jquery-ui.js.
$libraries['drupal.dialog']['js']['modules/ckeditor5/js/ckeditor5.dialog.fix.js'] = [];
}
// Only add translation processing if the locale module is enabled.
if (!$moduleHandler->moduleExists('locale')) {
return;
}
// All possibles CKEditor 5 languages that can be used by Drupal.
$ckeditor_langcodes = array_values(_ckeditor5_get_langcode_mapping());
if ($extension === 'core') {
// Generate libraries for each of the CKEditor 5 translation files so that
// the correct translation file can be attached depending on the current
// language. This makes sure that caching caches the appropriate language.
// Only create libraries for languages that have a mapping to Drupal.
foreach ($ckeditor_langcodes as $langcode) {
$libraries['ckeditor5.translations.' . $langcode] = [
'remote' => $libraries['ckeditor5']['remote'],
'version' => $libraries['ckeditor5']['version'],
'license' => $libraries['ckeditor5']['license'],
'dependencies' => [
'core/ckeditor5',
'core/ckeditor5.translations',
],
];
}
}
// Copied from \Drupal\Core\Asset\LibraryDiscoveryParser::buildByExtension().
if ($extension === 'core') {
$path = 'core';
}
else {
if ($moduleHandler->moduleExists($extension)) {
$extension_type = 'module';
}
else {
$extension_type = 'theme';
}
$path = \Drupal::getContainer()->get('extension.path.resolver')->getPath($extension_type, $extension);
}
foreach ($libraries as &$library) {
// The way to know if a library has a translation is to depend on the
// special "core/ckeditor5.translations" library.
if (empty($library['js']) || empty($library['dependencies']) || !in_array('core/ckeditor5.translations', $library['dependencies'])) {
continue;
}
foreach ($library['js'] as $file => $options) {
// Only look for translations on libraries defined with a relative path.
if (!empty($options['type']) && $options['type'] === 'external') {
continue;
}
// Path relative to the current extension folder.
$dirname = dirname($file);
// Path of the folder in the filesystem relative to the Drupal root.
$dir = $path . '/' . $dirname;
// Exclude protocol-free URI.
if (str_starts_with($dirname, '//')) {
continue;
}
// CKEditor 5 plugins are most likely added through composer and
// installed in the module exposing it. Suppose the file path is
// relative to the module and not in the /libraries/ folder.
// Collect translations based on filename, and add all existing
// translations files to the plugin library. Unnecessary translations
// will be filtered in ckeditor5_js_alter() hook.
$files = scandir("$dir/translations");
foreach ($files as $file) {
if (str_ends_with($file, '.js')) {
$langcode = basename($file, '.js');
// Only add languages that Drupal can understands.
if (in_array($langcode, $ckeditor_langcodes)) {
$library['js']["$dirname/translations/$langcode.js"] = [
// Used in ckeditor5_js_alter() to filter unwanted translations.
'ckeditor5_langcode' => $langcode,
'minified' => TRUE,
'preprocess' => TRUE,
];
}
}
}
}
}
}
/**
* Implements hook_js_alter().
*/
function ckeditor5_js_alter(&$javascript, AttachedAssetsInterface $assets, LanguageInterface $language) {
// This file means CKEditor 5 translations are in use on the page.
// @see locale_js_alter()
$placeholder_file = 'core/assets/vendor/ckeditor5/translation.js';
// This file is used to get a weight that will make it possible to aggregate
// all translation files in a single aggregate.
$ckeditor_dll_file = 'core/assets/vendor/ckeditor5/ckeditor5-dll/ckeditor5-dll.js';
if (isset($javascript[$placeholder_file])) {
// Use the placeholder file weight to set all the translations files weights
// so they can be aggregated together as expected.
$default_weight = $javascript[$placeholder_file]['weight'];
if (isset($javascript[$ckeditor_dll_file])) {
$default_weight = $javascript[$ckeditor_dll_file]['weight'];
}
// The placeholder file is not a real file, remove it from the list.
unset($javascript[$placeholder_file]);
// When the locale module isn't installed there are no translations.
if (!\Drupal::moduleHandler()->moduleExists('locale')) {
return;
}
$ckeditor5_language = _ckeditor5_get_langcode_mapping($language->getId());
// Remove all CKEditor 5 translations files that are not in the current
// language.
foreach ($javascript as $index => &$item) {
// This is not a CKEditor 5 translation file, skip it.
if (empty($item['ckeditor5_langcode'])) {
continue;
}
// This file is the correct translation for this page.
if ($item['ckeditor5_langcode'] === $ckeditor5_language) {
// Set the weight for the translation file to be able to have the
// translation files aggregated.
$item['weight'] = $default_weight;
}
// When the file doesn't match the langcode remove it from the page.
else {
// Remove files that don't match the language requested.
unset($javascript[$index]);
}
}
}
}
/**
* Implements hook_config_schema_info_alter().
*/
function ckeditor5_config_schema_info_alter(&$definitions) {
// In \Drupal\Tests\config\Functional\ConfigImportAllTest, this hook may be
// called without ckeditor5.pair.schema.yml being active.
if (!isset($definitions['ckeditor5_valid_pair__format_and_editor'])) {
return;
}
// @see filter.format.*.filters
$definitions['ckeditor5_valid_pair__format_and_editor']['mapping']['filters'] = $definitions['filter.format.*']['mapping']['filters'];
// @see @see editor.editor.*.image_upload
$definitions['ckeditor5_valid_pair__format_and_editor']['mapping']['image_upload'] = $definitions['editor.editor.*']['mapping']['image_upload'];
}
/**
* Retrieves the default theme's CKEditor 5 stylesheets.
*
* Themes may specify CSS files for use within CKEditor 5 by including a
* "ckeditor5-stylesheets" key in their .info.yml file.
*
* @code
* ckeditor5-stylesheets:
* - css/ckeditor.css
* @endcode
*
* @return string[]
* A list of paths to CSS files.
*/
function _ckeditor5_theme_css($theme = NULL): array {
$css = [];
if (!isset($theme)) {
$theme = \Drupal::config('system.theme')->get('default');
}
if (isset($theme) && $theme_path = \Drupal::service('extension.list.theme')->getPath($theme)) {
$info = \Drupal::service('extension.list.theme')->getExtensionInfo($theme);
if (isset($info['ckeditor5-stylesheets']) && $info['ckeditor5-stylesheets'] !== FALSE) {
$css = $info['ckeditor5-stylesheets'];
foreach ($css as $key => $url) {
// CSS URL is external or relative to Drupal root.
if (UrlHelper::isExternal($url) || $url[0] === '/') {
$css[$key] = $url;
}
// CSS URL is relative to theme.
else {
$css[$key] = '/' . $theme_path . '/' . $url;
}
}
}
if (isset($info['base theme'])) {
$css = array_merge(_ckeditor5_theme_css($info['base theme']), $css);
}
}
return $css;
}
/**
* Implements hook_ENTITY_TYPE_presave() for editor entities.
*/
function ckeditor5_editor_presave(EditorInterface $editor) {
if ($editor->getEditor() === 'ckeditor5') {
$settings = $editor->getSettings();
// @see ckeditor5_post_update_code_block()
if (in_array('codeBlock', $settings['toolbar']['items'], TRUE) && !isset($settings['plugins']['ckeditor5_codeBlock'])) {
// @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\CodeBlock::defaultConfiguration()
$settings['plugins']['ckeditor5_codeBlock'] = [
'languages' => [
['label' => 'Plain text', 'language' => 'plaintext'],
['label' => 'C', 'language' => 'c'],
['label' => 'C#', 'language' => 'cs'],
['label' => 'C++', 'language' => 'cpp'],
['label' => 'CSS', 'language' => 'css'],
['label' => 'Diff', 'language' => 'diff'],
['label' => 'HTML', 'language' => 'html'],
['label' => 'Java', 'language' => 'java'],
['label' => 'JavaScript', 'language' => 'javascript'],
['label' => 'PHP', 'language' => 'php'],
['label' => 'Python', 'language' => 'python'],
['label' => 'Ruby', 'language' => 'ruby'],
['label' => 'TypeScript', 'language' => 'typescript'],
['label' => 'XML', 'language' => 'xml'],
],
];
}
// @see ckeditor5_post_update_list_multiblock()
if (array_key_exists('ckeditor5_list', $settings['plugins']) && !array_key_exists('properties', $settings['plugins']['ckeditor5_list'])) {
// Update to the new config structure.
$settings['plugins']['ckeditor5_list'] = [
'properties' => $settings['plugins']['ckeditor5_list'],
'multiBlock' => TRUE,
];
}
// @see ckeditor5_post_update_list_start_reversed()
if (in_array('numberedList', $settings['toolbar']['items'], TRUE) && array_key_exists('ckeditor5_sourceEditing', $settings['plugins'])) {
$source_edited = HTMLRestrictions::fromString(implode(' ', $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']));
$format_restrictions = HTMLRestrictions::fromTextFormat($editor->getFilterFormat());
// If <ol start> is not allowed through Source Editing (the only way it
// could possibly be supported until now), and it is not an unrestricted
// text format (such as "Full HTML"), then set the new "startIndex"
// setting for the List plugin to false.
// Except … that this update path was added too late, and many sites have
// in the meantime edited their text editor configuration through the UI,
// in which case they may already have set it. If that is the case: do not
// override it.
$ol_start = HTMLRestrictions::fromString('<ol start>');
if (!array_key_exists('ckeditor5_list', $settings['plugins']) || !array_key_exists('startIndex', $settings['plugins']['ckeditor5_list']['properties'])) {
$settings['plugins']['ckeditor5_list']['properties']['startIndex'] = $ol_start->diff($source_edited)
->allowsNothing() || $format_restrictions->isUnrestricted();
}
// Same for <ol reversed> and "reversed".
$ol_reversed = HTMLRestrictions::fromString('<ol reversed>');
if (!array_key_exists('ckeditor5_list', $settings['plugins']) || !array_key_exists('reversed', $settings['plugins']['ckeditor5_list']['properties'])) {
$settings['plugins']['ckeditor5_list']['properties']['reversed'] = $ol_reversed->diff($source_edited)
->allowsNothing() || $format_restrictions->isUnrestricted();
}
// Match the sort order in ListPlugin::defaultConfiguration().
ksort($settings['plugins']['ckeditor5_list']['properties']);
// Update the Source Editing configuration too.
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = $source_edited
->diff($ol_start)
->diff($ol_reversed)
->toCKEditor5ElementsArray();
}
$editor->setSettings($settings);
}
}

View File

@@ -0,0 +1,144 @@
<?php
/**
* @file
* Post update functions for CKEditor 5.
*/
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\Core\Config\Entity\ConfigEntityUpdater;
use Drupal\editor\Entity\Editor;
// cspell:ignore multiblock
/**
* Implements hook_removed_post_updates().
*/
function ckeditor5_removed_post_updates() {
return [
'ckeditor5_post_update_alignment_buttons' => '10.0.0',
];
}
/**
* The image toolbar item changed from `uploadImage` to `drupalInsertImage`.
*/
function ckeditor5_post_update_image_toolbar_item(&$sandbox = []) {
$config_entity_updater = \Drupal::classResolver(ConfigEntityUpdater::class);
$callback = function (Editor $editor) {
// Only try to update editors using CKEditor 5.
if ($editor->getEditor() !== 'ckeditor5') {
return FALSE;
}
$needs_update = FALSE;
// Only update if the editor is using the `uploadImage` toolbar item.
$settings = $editor->getSettings();
if (is_array($settings['toolbar']['items']) && in_array('uploadImage', $settings['toolbar']['items'], TRUE)) {
// Replace `uploadImage` with `drupalInsertImage`.
$settings['toolbar']['items'] = str_replace('uploadImage', 'drupalInsertImage', $settings['toolbar']['items']);
// `<img data-entity-uuid data-entity-type>` are implicitly supported when
// uploads are enabled as the attributes are necessary for upload
// functionality. If uploads aren't enabled, these attributes must still
// be supported to ensure existing content that may have them (despite
// uploads being disabled) remains editable. In this use case, the
// attributes are added to the `ckeditor5_sourceEditing` allowed tags.
if (!$editor->getImageUploadSettings()['status']) {
// Add `sourceEditing` toolbar item if it does not already exist.
if (!in_array('sourceEditing', $settings['toolbar']['items'], TRUE)) {
$settings['toolbar']['items'][] = '|';
$settings['toolbar']['items'][] = 'sourceEditing';
// @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing::defaultConfiguration()
$settings['plugins']['ckeditor5_sourceEditing'] = ['allowed_tags' => []];
}
// Update configuration.
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = HTMLRestrictions::fromString(implode(' ', $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']))
->merge(HTMLRestrictions::fromString('<img data-entity-uuid data-entity-type>'))
->toCKEditor5ElementsArray();
}
$needs_update = TRUE;
}
if ($needs_update) {
$editor->setSettings($settings);
}
return $needs_update;
};
$config_entity_updater->update($sandbox, 'editor', $callback);
}
/**
* Updates Text Editors using CKEditor 5 to sort plugin settings by plugin key.
*/
function ckeditor5_post_update_plugins_settings_export_order(&$sandbox = []) {
$config_entity_updater = \Drupal::classResolver(ConfigEntityUpdater::class);
$config_entity_updater->update($sandbox, 'editor', function (Editor $editor): bool {
// Only try to update editors using CKEditor 5.
if ($editor->getEditor() !== 'ckeditor5') {
return FALSE;
}
$settings = $editor->getSettings();
// Nothing to do if there are fewer than two plugins with settings.
if (count($settings['plugins']) < 2) {
return FALSE;
}
ksort($settings['plugins']);
$editor->setSettings($settings);
return TRUE;
});
}
/**
* Updates Text Editors using CKEditor 5 Code Block.
*/
function ckeditor5_post_update_code_block(&$sandbox = []) {
$config_entity_updater = \Drupal::classResolver(ConfigEntityUpdater::class);
$config_entity_updater->update($sandbox, 'editor', function (Editor $editor): bool {
// Only try to update editors using CKEditor 5.
if ($editor->getEditor() !== 'ckeditor5') {
return FALSE;
}
$settings = $editor->getSettings();
// @see ckeditor5_editor_presave()
return in_array('codeBlock', $settings['toolbar']['items'], TRUE);
});
}
/**
* Updates Text Editors using CKEditor 5.
*/
function ckeditor5_post_update_list_multiblock(&$sandbox = []) {
$config_entity_updater = \Drupal::classResolver(ConfigEntityUpdater::class);
$config_entity_updater->update($sandbox, 'editor', function (Editor $editor): bool {
// Only try to update editors using CKEditor 5.
if ($editor->getEditor() !== 'ckeditor5') {
return FALSE;
}
$settings = $editor->getSettings();
// @see ckeditor5_editor_presave()
return array_key_exists('ckeditor5_list', $settings['plugins']);
});
}
/**
* Updates Text Editors using CKEditor 5 to native List "start" functionality.
*/
function ckeditor5_post_update_list_start_reversed(&$sandbox = []) {
$config_entity_updater = \Drupal::classResolver(ConfigEntityUpdater::class);
$config_entity_updater->update($sandbox, 'editor', function (Editor $editor): bool {
// Only try to update editors using CKEditor 5.
if ($editor->getEditor() !== 'ckeditor5') {
return FALSE;
}
$settings = $editor->getSettings();
// @see ckeditor5_editor_presave()
return in_array('numberedList', $settings['toolbar']['items'], TRUE)
&& array_key_exists('ckeditor5_sourceEditing', $settings['plugins']);
});
}

View File

@@ -0,0 +1,27 @@
ckeditor5.upload_image:
path: '/ckeditor5/upload-image/{editor}'
defaults:
_controller: '\Drupal\ckeditor5\Controller\CKEditor5ImageController::upload'
methods: [POST]
requirements:
_entity_access: 'editor.use'
_custom_access: '\Drupal\ckeditor5\Controller\CKEditor5ImageController::imageUploadEnabledAccess'
_csrf_token: 'TRUE'
options:
parameters:
editor:
type: entity:editor
ckeditor5.media_entity_metadata:
path: '/ckeditor5/{editor}/media-entity-metadata'
defaults:
_controller: '\Drupal\ckeditor5\Controller\CKEditor5MediaController::mediaEntityMetadata'
methods: [GET]
requirements:
_entity_access: 'editor.use'
_custom_access: '\Drupal\ckeditor5\Controller\CKEditor5MediaController::access'
_csrf_token: 'TRUE'
options:
parameters:
editor:
type: entity:editor

View File

@@ -0,0 +1,33 @@
services:
_defaults:
autoconfigure: true
plugin.manager.ckeditor5.plugin:
class: Drupal\ckeditor5\Plugin\CKEditor5PluginManager
parent: default_plugin_manager
Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface: '@plugin.manager.ckeditor5.plugin'
# @todo Remove in Drupal 11: https://www.drupal.org/project/ckeditor5/issues/3239012
plugin.manager.ckeditor4to5upgrade.plugin:
public: false
class: Drupal\ckeditor5\Plugin\CKEditor4To5UpgradePluginManager
parent: default_plugin_manager
ckeditor5.smart_default_settings:
class: Drupal\ckeditor5\SmartDefaultSettings
arguments:
- '@plugin.manager.ckeditor5.plugin'
- '@plugin.manager.ckeditor4to5upgrade.plugin'
- '@logger.channel.ckeditor5'
- '@module_handler'
- '@current_user'
Drupal\ckeditor5\SmartDefaultSettings: '@ckeditor5.smart_default_settings'
ckeditor5.stylesheets.message:
class: Drupal\ckeditor5\CKEditor5StylesheetsMessage
arguments:
- '@theme_handler'
- '@config.factory'
Drupal\ckeditor5\CKEditor5StylesheetsMessage: '@ckeditor5.stylesheets.message'
ckeditor5.ckeditor5_cache_tag:
class: Drupal\ckeditor5\EventSubscriber\CKEditor5CacheTag
arguments: ['@cache_tags.invalidator']
logger.channel.ckeditor5:
parent: logger.channel_base
arguments: [ 'ckeditor5' ]

View File

@@ -0,0 +1,45 @@
.ckeditor5-toolbar-button-alignment\:left {
background-image: url(../icons/align-left.svg);
}
.ckeditor5-toolbar-button-alignment\:right {
background-image: url(../icons/align-right.svg);
}
.ckeditor5-toolbar-button-alignment\:center {
background-image: url(../icons/align-center.svg);
}
.ckeditor5-toolbar-button-alignment\:justify {
background-image: url(../icons/align-justify.svg);
}
.ckeditor5-toolbar-button-alignment {
background-image: url(../icons/align-left.svg);
}
.ckeditor5-toolbar-button-alignment {
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 20px;
background-position-x: 10px;
}
[dir="rtl"] .ckeditor5-toolbar-button-alignment {
padding-right: 0;
padding-left: 20px;
background-position-x: 30px;
}
.ckeditor5-toolbar-button-alignment::after {
position: relative;
right: -10px;
display: inline-block;
width: 7px;
height: 7px;
content: "";
transform: rotate(135deg);
color: #000;
border-width: 2px 2px 0 0;
border-style: solid;
background-position-x: -10px;
}
[dir="rtl"] .ckeditor5-toolbar-button-alignment::after {
right: 10px;
background-position-x: 10px;
}

View File

@@ -0,0 +1,27 @@
.ckeditor5-toolbar-button-bold {
background-image: url(../icons/bold.svg);
}
.ckeditor5-toolbar-button-italic {
background-image: url(../icons/italic.svg);
}
.ckeditor5-toolbar-button-underline {
background-image: url(../icons/underline.svg);
}
.ckeditor5-toolbar-button-code {
background-image: url(../icons/code.svg);
}
.ckeditor5-toolbar-button-strikethrough {
background-image: url(../icons/strikethrough.svg);
}
.ckeditor5-toolbar-button-subscript {
background-image: url(../icons/subscript.svg);
}
.ckeditor5-toolbar-button-superscript {
background-image: url(../icons/superscript.svg);
}

View File

@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-blockQuote {
background-image: url(../icons/blockquote.svg);
}

View File

@@ -0,0 +1,3 @@
.ui-dialog ~ .ck-body-wrapper {
--ck-z-panel: 1261;
}

View File

@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-codeBlock {
background-image: url(../icons/code-block.svg);
}

View File

@@ -0,0 +1,4 @@
/* cspell:ignore medialibrary */
.ckeditor5-toolbar-button-drupalMedia {
background-image: url(../icons/medialibrary.svg);
}

View File

@@ -0,0 +1,109 @@
/**
* @file
* Styles for the Drupal Media in CKEditor 5.
*
* Most of these styles are written to match those in the CKEditor 5 image
* plugin to provide a consistent editing experience.
*/
.ck .drupal-media {
position: relative;
display: table;
clear: both;
min-width: 50px;
}
.ck .drupal-media [data-drupal-media-preview] {
pointer-events: none;
}
.ck-content .drupal-media img {
display: block;
min-width: 100%;
max-width: 100%;
margin: 0 auto;
}
.ck-content .drupal-media > figcaption {
display: table-caption;
padding: 0.6em;
caption-side: bottom;
word-break: break-word;
color: hsl(0, 0%, 20%);
outline-offset: -1px;
background-color: hsl(0, 0%, 97%);
font-size: 0.75em;
}
.ck.ck-editor__editable .drupal-media__caption_highlighted {
animation: drupal-media-caption-highlight 0.6s ease-out;
}
@keyframes drupal-media-caption-highlight {
0% {
background-color: hsl(52, 100%, 50%);
}
100% {
background-color: hsl(0, 0%, 97%);
}
}
.ck .drupal-media__metadata-error {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border: 1px solid #e29700;
border-radius: 50%;
background: #fdf8ed;
}
.ck .drupal-media__metadata-error-icon {
display: block;
width: 28px;
height: 28px;
background: url("../../../misc/icons/e29700/warning.svg") no-repeat center 4px;
background-size: 18px;
}
.ck .drupal-media__metadata-error .ck-tooltip {
display: block;
overflow: visible;
}
.ck .drupal-media__metadata-error:hover .ck-tooltip {
visibility: visible;
opacity: 1;
}
.ck .drupal-media__metadata-error:hover .ck-tooltip__text {
display: block;
width: 240px;
}
.ck.ck-media-alternative-text-form {
min-width: 300px;
max-width: 600px;
padding: 0;
}
.ck.ck-media-alternative-text-form .ck-labeled-field-view,
.ck.ck-media-alternative-text-form .ck-media-alternative-text-form__default-alt-text {
margin: var(--ck-spacing-large) var(--ck-spacing-large) var(--ck-spacing-small);
}
.ck.ck-media-alternative-text-form .ck-labeled-field-view .ck-input-text {
width: 100%;
}
.ck.ck-media-alternative-text-form .ck-button {
width: 50%;
margin: var(--ck-spacing-large) 0 0 0;
padding: var(--ck-spacing-standard);
border: 0;
border-top: 1px solid var(--ck-color-base-border);
border-radius: 0;
}
.ck.ck .ck-media-alternative-text-form__default-alt-text-label {
font-weight: bold;
}
.ck.ck .ck-media-alternative-text-form__default-alt-text-label,
.ck.ck .ck-media-alternative-text-form__default-alt-text-value {
white-space: normal;
}

View File

@@ -0,0 +1,31 @@
/**
* @file
* Styles for the CKEditor 5 editor.
*/
/* Convert low opacity icons to full opacity. */
.ck-button:not(.ck-disabled) .ck-icon * {
opacity: 1 !important;
fill-opacity: 1 !important;
}
.ck-editor__main > :is(.ck-editor__editable, .ck-source-editing-area) {
/* Set the min-height equal to configuration value for the number of rows.
* The `--ck-min-height` value is set on the parent `.ck-editor` element by
* JavaScript. We add that there because the `.ck-editor__editable` element's
* inline styles are cleared on focus. */
min-height: var(--ck-min-height);
/* Set the max-height to not grow beyond the height of the viewport (minus
* any toolbars. */
max-height: calc(100vh - var(--drupal-displace-offset-top, 0px) - var(--drupal-displace-offset-bottom, 0px) - 20px);
}
/* Show the scrollbar on the source editing area. */
.ck-editor__main > .ck-source-editing-area textarea {
overflow: auto;
}
/* Enhance visibility of selected/active buttons on the toolbar. */
.ck-toolbar__items .ck.ck-button.ck-on {
border: 1px solid var(--ck-color-button-on-color);
}

View File

@@ -0,0 +1,52 @@
.ckeditor5-toolbar-button-divider {
background-image: url(../icons/divider.svg);
}
.ckeditor5-toolbar-button-wrapping {
background-image: url(../icons/separator.svg);
}
.ckeditor5-toolbar-button-undo {
background-image: url(../icons/undo.svg);
}
.ckeditor5-toolbar-button-redo {
background-image: url(../icons/redo.svg);
}
.ckeditor5-toolbar-button-heading {
display: flex;
align-items: center;
justify-content: space-between;
width: 100px;
color: #000;
}
.ckeditor5-toolbar-button-heading::before {
margin-left: 10px;
/* For browsers which don't support alt content, eg FireFox */
content: "Heading";
content: "Heading" / "";
font-size: 14px;
}
[dir="rtl"] .ckeditor5-toolbar-button-heading::before {
margin-right: 10px;
margin-left: 0;
}
.ckeditor5-toolbar-button-heading::after {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 10px;
content: "";
transform: rotate(135deg);
border-width: 2px 2px 0 0;
border-style: solid;
}
[dir="rtl"] .ckeditor5-toolbar-button-heading::after {
margin-right: 0;
margin-left: 10px;
}

View File

@@ -0,0 +1,23 @@
/**
* @file
* Styles for header options in the admin UI.
*
* These styles are copied from CKEditor 5's editor styles, which load when an
* editor is present, but are not guaranteed to load in the admin UI.
*/
.ck.ck-heading_heading1 {
font-size: 20px;
}
.ck.ck-heading_heading2 {
font-size: 17px;
}
.ck.ck-heading_heading3 {
font-size: 14px;
}
.ck[class*="ck-heading_heading"] {
font-weight: bold;
}

View File

@@ -0,0 +1,4 @@
/* cspell:ignore horizontalline */
.ckeditor5-toolbar-button-horizontalLine {
background-image: url(../icons/horizontalline.svg);
}

View File

@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-drupalInsertImage {
background-image: url(../icons/image.svg);
}

View File

@@ -0,0 +1,77 @@
/* cspell:ignore switchbutton */
/* https://css-tricks.com/the-raven-technique-one-step-closer-to-container-queries */
.ck .image,
.ck .image-inline {
--base-size: 100%;
--breakpoint-wide: 400px;
--breakpoint-medium: 100px;
--is-wide: clamp(0px, var(--base-size) - var(--breakpoint-wide), 1px);
--is-medium: calc(clamp(0px, var(--base-size) - var(--breakpoint-medium), 1px) - var(--is-wide));
--is-small: calc(1px - (var(--is-medium) + var(--is-wide)));
}
.ck.ck-responsive-form.ck-text-alternative-form--with-decorative-toggle {
width: auto;
}
.ck.ck-responsive-form .ck.ck-text-alternative-form__decorative-toggle,
.ck.ck-responsive-form .ck.ck-text-alternative-form__decorative-toggle .ck-switchbutton {
width: 100%;
}
.ck .image,
.ck .image-inline {
position: relative;
}
.ck .image-alternative-text-missing-wrapper {
position: absolute;
right: 10px;
bottom: 10px;
overflow: hidden;
max-width: calc((var(--is-small) * 0) + (var(--is-medium) * 33) + (var(--is-wide) * 999999));
border-left: calc((var(--is-small) * 0) + (var(--is-medium) * 3) + (var(--is-wide) * 3)) solid #ffd23f;
border-radius: 2px;
background: #232429;
font-size: 14px;
}
.ck figcaption ~ .image-alternative-text-missing-wrapper {
top: 10px;
bottom: auto;
}
.ck .image-alternative-text-missing-wrapper .ck.ck-button {
padding: 12px 12px 12px 8px;
cursor: pointer;
color: #fff;
background: none !important; /* Override background for all states. */
}
.ck .image-alternative-text-missing-wrapper .ck.ck-button::before {
width: 16px;
height: 16px;
padding-right: 8px;
content: "";
background: url("../icons/warning.svg") left center no-repeat;
}
.ck .image-alternative-text-missing-wrapper .ck.ck-button::after {
display: inline-block;
width: 12px;
height: 12px;
padding-left: 2rem;
content: "";
background: url("../icons/caret.svg") right center no-repeat;
font-size: 18px;
font-weight: bold;
}
.ck .image-alternative-text-missing-wrapper .ck-tooltip {
display: block;
overflow: visible;
}
.ck .image-alternative-text-missing-wrapper:hover .ck-tooltip {
visibility: visible;
opacity: 1;
}
.ck .image-alternative-text-missing-wrapper:hover .ck-tooltip__text {
display: block;
width: 240px;
}

View File

@@ -0,0 +1,7 @@
.ckeditor5-toolbar-button-indent {
background-image: url(../icons/indent.svg);
}
.ckeditor5-toolbar-button-outdent {
background-image: url(../icons/outdent.svg);
}

View File

@@ -0,0 +1,36 @@
.ckeditor5-toolbar-button-textPartLanguage {
display: flex;
align-items: center;
justify-content: space-between;
width: 110px;
color: #000;
}
.ckeditor5-toolbar-button-textPartLanguage::before {
margin-left: 10px;
/* For browsers which don't support alt content, eg FireFox */
content: "Language";
content: "Language" / "";
font-size: 14px;
}
[dir="rtl"] .ckeditor5-toolbar-button-textPartLanguage::before {
margin-right: 10px;
margin-left: 0;
}
.ckeditor5-toolbar-button-textPartLanguage::after {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 10px;
content: "";
transform: rotate(135deg);
border-width: 2px 2px 0 0;
border-style: solid;
}
[dir="rtl"] .ckeditor5-toolbar-button-textPartLanguage::after {
margin-right: 0;
margin-left: 10px;
}

View File

@@ -0,0 +1,19 @@
/**
* @file
* Language: add styling for elements that have a language attribute.
*/
/**
* Show the user that a 'lang' tag has been applied by adding a thin dotted
* border. We also append the value of the tag between brackets, for example:
* '(en)'. Since the html element has a 'lang' attribute too we only target
* elements within the html scope.
*/
.ck-content [lang] {
outline: 1px dotted gray;
}
.ck-content [lang]::after {
content: " (" attr(lang) ")";
color: #666;
font-size: 10px;
}

View File

@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-link {
background-image: url(../icons/link.svg);
}

View File

@@ -0,0 +1,8 @@
/* cspell:ignore bulletedlist numberedlist */
.ckeditor5-toolbar-button-bulletedList {
background-image: url(../icons/bulletedlist.svg);
}
.ckeditor5-toolbar-button-numberedList {
background-image: url(../icons/numberedlist.svg);
}

View File

@@ -0,0 +1,18 @@
.ck-content .drupal-media-style-align-right {
float: right;
margin-left: 1.5rem;
}
.ck-content .drupal-media-style-align-left {
float: left;
margin-right: 1.5rem;
}
.ck-content .drupal-media-style-align-left,
.ck-content .drupal-media-style-align-right {
clear: both;
max-width: 50%;
}
.ck-content .drupal-media-style-align-center {
max-width: 50%;
margin-right: auto;
margin-left: auto;
}

View File

@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-removeFormat {
background-image: url(../icons/remove-format.svg);
}

View File

@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-showBlocks {
background-image: url(../icons/show-blocks.svg);
}

View File

@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-sourceEditing {
background-image: url(../icons/source-editing.svg);
}

View File

@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-specialCharacters {
background-image: url(../icons/special-characters.svg);
}

View File

@@ -0,0 +1,36 @@
.ckeditor5-toolbar-button-style {
display: flex;
align-items: center;
justify-content: space-between;
width: 110px;
color: #000;
}
.ckeditor5-toolbar-button-style::before {
margin-left: 10px;
/* For browsers which don't support alt content, eg FireFox */
content: "Style";
content: "Style" / "";
font-size: 14px;
}
[dir="rtl"] .ckeditor5-toolbar-button-style::before {
margin-right: 10px;
margin-left: 0;
}
.ckeditor5-toolbar-button-style::after {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 10px;
content: "";
transform: rotate(135deg);
border-width: 2px 2px 0 0;
border-style: solid;
}
[dir="rtl"] .ckeditor5-toolbar-button-style::after {
margin-right: 0;
margin-left: 10px;
}

View File

@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-insertTable {
background-image: url(../icons/table.svg);
}

View File

@@ -0,0 +1,11 @@
/**
* Allow users to specify the background color in all themes.
*
* (Users can set this using CKEditor 5's optional TableProperties and
* TableCellProperties plugins.)
*/
.ck tr,
.ck th,
.ck td {
background-color: transparent;
}

View File

@@ -0,0 +1,99 @@
.ckeditor5-toolbar-disabled {
display: flex;
justify-content: space-between;
}
.ckeditor5-toolbar-available {
flex: 1;
}
.ckeditor5-toolbar-tray {
display: flex;
flex-flow: row wrap;
align-items: center;
min-height: 40px;
margin: 0 0 0.5em 0;
padding: 0;
list-style: none;
/* Disallow any user selections in the drag-and-drop toolbar config UI. */
user-select: none;
}
.ckeditor5-toolbar-active__buttons {
margin: 5px 0;
padding: 0.1667em 0.1667em 0.08em;
border: 1px solid #c4c4c4;
border-radius: 2px;
background: #fafafa;
}
.ckeditor5-toolbar-item,
.ckeditor5-toolbar-button {
display: block;
min-width: 36px;
height: 36px;
cursor: move;
border-radius: 2px;
}
.ckeditor5-toolbar-item {
position: relative;
margin: 5px 8px 5px 0;
}
.ckeditor5-toolbar-disabled .ckeditor5-toolbar-item {
border: 1px solid #e6e6e6;
}
.ckeditor5-toolbar-button {
border: none;
background-color: #eee;
background-repeat: no-repeat;
background-position: center;
background-size: 20px;
}
.ckeditor5-toolbar-button:focus,
.ckeditor5-toolbar-button:hover {
color: #000;
background-color: #e6e6e6;
}
.ckeditor5-toolbar-button:focus,
.ckeditor5-toolbar-button:hover,
.ckeditor5-toolbar-button {
text-decoration: none;
}
.ckeditor5-toolbar-tooltip {
position: absolute;
z-index: 1;
left: 50%;
display: block;
padding: 6px 10px;
transform: translate(-50%, 2px);
text-transform: capitalize;
color: #fff;
border-radius: 3px;
background: #333;
font-size: 12px;
line-height: 1;
}
.ckeditor5-toolbar-tooltip::before {
position: absolute;
top: -10px;
left: 50%;
width: 0;
height: 5px;
content: "";
transform: translateX(-50%);
border-right: solid 5px transparent;
border-bottom: solid 5px #333;
border-left: solid 5px transparent;
}
.ckeditor5-toolbar-button + .ckeditor5-toolbar-tooltip {
visibility: hidden;
}
.ckeditor5-toolbar-button[data-expanded="true"] + .ckeditor5-toolbar-tooltip {
visibility: visible;
}

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 8c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm2.286 4c0 .414.336.75.75.75h9.928a.75.75 0 1 0 0-1.5H5.036a.75.75 0 0 0-.75.75zm0-8c0 .414.336.75.75.75h9.928a.75.75 0 1 0 0-1.5H5.036a.75.75 0 0 0-.75.75z"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 8c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 4c0 .414.336.75.75.75h9.929a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0-8c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75z"/></svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 8c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 4c0 .414.336.75.75.75h9.929a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0-8c0 .414.336.75.75.75h9.929a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75z"/></svg>

After

Width:  |  Height:  |  Size: 379 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M18 3.75a.75.75 0 0 1-.75.75H2.75a.75.75 0 1 1 0-1.5h14.5a.75.75 0 0 1 .75.75zm0 8a.75.75 0 0 1-.75.75H2.75a.75.75 0 1 1 0-1.5h14.5a.75.75 0 0 1 .75.75zm0 4a.75.75 0 0 1-.75.75H7.321a.75.75 0 1 1 0-1.5h9.929a.75.75 0 0 1 .75.75zm0-8a.75.75 0 0 1-.75.75H7.321a.75.75 0 1 1 0-1.5h9.929a.75.75 0 0 1 .75.75z"/></svg>

After

Width:  |  Height:  |  Size: 382 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 10.423a6.5 6.5 0 0 1 6.056-6.408l.038.67C6.448 5.423 5.354 7.663 5.22 10H9c.552 0 .5.432.5.986v4.511c0 .554-.448.503-1 .503h-5c-.552 0-.5-.449-.5-1.003v-4.574zm8 0a6.5 6.5 0 0 1 6.056-6.408l.038.67c-2.646.739-3.74 2.979-3.873 5.315H17c.552 0 .5.432.5.986v4.511c0 .554-.448.503-1 .503h-5c-.552 0-.5-.449-.5-1.003v-4.574z"></path></svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.187 17H5.773c-.637 0-1.092-.138-1.364-.415-.273-.277-.409-.718-.409-1.323V4.738c0-.617.14-1.062.419-1.332.279-.27.73-.406 1.354-.406h4.68c.69 0 1.288.041 1.793.124.506.083.96.242 1.36.478.341.197.644.447.906.75a3.262 3.262 0 0 1 .808 2.162c0 1.401-.722 2.426-2.167 3.075C15.05 10.175 16 11.315 16 13.01a3.756 3.756 0 0 1-2.296 3.504 6.1 6.1 0 0 1-1.517.377c-.571.073-1.238.11-2 .11zm-.217-6.217H7v4.087h3.069c1.977 0 2.965-.69 2.965-2.072 0-.707-.256-1.22-.768-1.537-.512-.319-1.277-.478-2.296-.478zM7 5.13v3.619h2.606c.729 0 1.292-.067 1.69-.2a1.6 1.6 0 0 0 .91-.765c.165-.267.247-.566.247-.897 0-.707-.26-1.176-.778-1.409-.519-.232-1.31-.348-2.375-.348H7z"></path></svg>

After

Width:  |  Height:  |  Size: 746 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 5.75c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zm-6 0C1 4.784 1.777 4 2.75 4c.966 0 1.75.777 1.75 1.75 0 .966-.777 1.75-1.75 1.75C1.784 7.5 1 6.723 1 5.75zm6 9c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zm-6 0c0-.966.777-1.75 1.75-1.75.966 0 1.75.777 1.75 1.75 0 .966-.777 1.75-1.75 1.75-.966 0-1.75-.777-1.75-1.75z"></path></svg>

After

Width:  |  Height:  |  Size: 452 B

View File

@@ -0,0 +1 @@
<svg width="12" height="12" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.75 10.5 8.25 6l-4.5-4.5" stroke="#FFD23F" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 154 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M12.87 12.61a.75.75 0 0 1-.089.976l-.085.07-3.154 2.254 3.412 2.414a.75.75 0 0 1 .237.95l-.057.095a.75.75 0 0 1-.95.237l-.096-.058-4.272-3.022-.003-1.223 4.01-2.867a.75.75 0 0 1 1.047.174zm2.795-.231.095.057 4.011 2.867-.003 1.223-4.272 3.022-.095.058a.75.75 0 0 1-.88-.151l-.07-.086-.058-.095a.75.75 0 0 1 .15-.88l.087-.07 3.412-2.414-3.154-2.253-.085-.071a.75.75 0 0 1 .862-1.207zM16 0a2 2 0 0 1 2 2v9.354l-.663-.492-.837-.001V2a.5.5 0 0 0-.5-.5H2a.5.5 0 0 0-.5.5v15a.5.5 0 0 0 .5.5h3.118L7.156 19H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h14zM5.009 15l.003 1H3v-1h2.009zm2.188-2-1.471 1H5v-1h2.197zM10 11v.095L8.668 12H7v-1h3zm4-2v1H7V9h7zm0-2v1H7V7h7zm-4-2v1H5V5h5zM6 3v1H3V3h3z"/></svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m12.5 5.7 5.2 3.9v1.3l-5.6 4c-.1.2-.3.2-.5.2-.3-.1-.6-.7-.6-1l.3-.4 4.7-3.5L11.5 7l-.2-.2c-.1-.3-.1-.6 0-.8.2-.2.5-.4.8-.4a.8.8 0 0 1 .4.1zm-5.2 0L2 9.6v1.3l5.6 4c.1.2.3.2.5.2.3-.1.7-.7.6-1 0-.1 0-.3-.2-.4l-5-3.5L8.2 7l.2-.2c.1-.3.1-.6 0-.8-.2-.2-.5-.4-.8-.4a.8.8 0 0 0-.3.1z"/></svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="2" width="2" height="16" /></svg>

After

Width:  |  Height:  |  Size: 109 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 9h16v2H2z"/></svg>

After

Width:  |  Height:  |  Size: 91 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M6.91 10.54c.26-.23.64-.21.88.03l3.36 3.14 2.23-2.06a.64.64 0 0 1 .87 0l2.52 2.97V4.5H3.2v10.12l3.71-4.08zm10.27-7.51c.6 0 1.09.47 1.09 1.05v11.84c0 .59-.49 1.06-1.09 1.06H2.79c-.6 0-1.09-.47-1.09-1.06V4.08c0-.58.49-1.05 1.1-1.05h14.38zm-5.22 5.56a1.96 1.96 0 1 1 3.4-1.96 1.96 1.96 0 0 1-3.4 1.96z"></path></svg>

After

Width:  |  Height:  |  Size: 383 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm5 6c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zM2.75 16.5h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 1 0 0 1.5zM1.632 6.95 5.02 9.358a.4.4 0 0 1-.013.661l-3.39 2.207A.4.4 0 0 1 1 11.892V7.275a.4.4 0 0 1 .632-.326z"/></svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m9.586 14.633.021.004c-.036.335.095.655.393.962.082.083.173.15.274.201h1.474a.6.6 0 1 1 0 1.2H5.304a.6.6 0 0 1 0-1.2h1.15c.474-.07.809-.182 1.005-.334.157-.122.291-.32.404-.597l2.416-9.55a1.053 1.053 0 0 0-.281-.823 1.12 1.12 0 0 0-.442-.296H8.15a.6.6 0 0 1 0-1.2h6.443a.6.6 0 1 1 0 1.2h-1.195c-.376.056-.65.155-.823.296-.215.175-.423.439-.623.79l-2.366 9.347z"/></svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.077 15 .991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955.741.741 0 0 1 .12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184z"/></svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M19.1873 4.86414L10.2509 6.86414V7.02335H10.2499V15.5091C9.70972 15.1961 9.01793 15.1048 8.34069 15.3136C7.12086 15.6896 6.41013 16.8967 6.75322 18.0096C7.09631 19.1226 8.3633 19.72 9.58313 19.344C10.6666 19.01 11.3484 18.0203 11.2469 17.0234H11.2499V9.80173L18.1803 8.25067V14.3868C17.6401 14.0739 16.9483 13.9825 16.2711 14.1913C15.0513 14.5674 14.3406 15.7744 14.6836 16.8875C15.0267 18.0004 16.2937 18.5978 17.5136 18.2218C18.597 17.8877 19.2788 16.8982 19.1773 15.9011H19.1803V8.02687L19.1873 8.0253V4.86414Z" /><path fill-rule="evenodd" clip-rule="evenodd" d="M13.5039 0.743652H0.386932V12.1603H13.5039V0.743652ZM12.3379 1.75842H1.55289V11.1454H1.65715L4.00622 8.86353L6.06254 10.861L9.24985 5.91309L11.3812 9.22179L11.7761 8.6676L12.3379 9.45621V1.75842ZM6.22048 4.50869C6.22048 5.58193 5.35045 6.45196 4.27722 6.45196C3.20398 6.45196 2.33395 5.58193 2.33395 4.50869C2.33395 3.43546 3.20398 2.56543 4.27722 2.56543C5.35045 2.56543 6.22048 3.43546 6.22048 4.50869Z" /></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 5.75c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zM3.5 3v5H2V3.7H1v-1h2.5V3zM.343 17.857l2.59-3.257H2.92a.6.6 0 1 0-1.04 0H.302a2 2 0 1 1 3.995 0h-.001c-.048.405-.16.734-.333.988-.175.254-.59.692-1.244 1.312H4.3v1h-4l.043-.043zM7 14.75a.75.75 0 0 1 .75-.75h9.5a.75.75 0 1 1 0 1.5h-9.5a.75.75 0 0 1-.75-.75z"></path></svg>

After

Width:  |  Height:  |  Size: 417 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm5 6c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zM2.75 16.5h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 1 0 0 1.5zm1.618-9.55L.98 9.358a.4.4 0 0 0 .013.661l3.39 2.207A.4.4 0 0 0 5 11.892V7.275a.4.4 0 0 0-.632-.326z"/></svg>

After

Width:  |  Height:  |  Size: 388 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m14.958 9.367-2.189 1.837a.75.75 0 0 0 .965 1.149l3.788-3.18a.747.747 0 0 0 .21-.284.75.75 0 0 0-.17-.945L13.77 4.762a.75.75 0 1 0-.964 1.15l2.331 1.955H6.22A.75.75 0 0 0 6 7.9a4 4 0 1 0 1.477 7.718l-.344-1.489A2.5 2.5 0 1 1 6.039 9.4l-.008-.032h8.927z"/></svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.69 14.915c.053.052.173.083.36.093a.366.366 0 0 1 .345.485l-.003.01a.738.738 0 0 1-.697.497h-2.67a.374.374 0 0 1-.353-.496l.013-.038a.681.681 0 0 1 .644-.458c.197-.012.325-.043.386-.093a.28.28 0 0 0 .072-.11L9.592 4.5H6.269c-.359-.017-.609.013-.75.09-.142.078-.289.265-.442.563-.192.29-.516.464-.864.464H4.17a.43.43 0 0 1-.407-.569L4.46 3h13.08l-.62 2.043a.81.81 0 0 1-.775.574h-.114a.486.486 0 0 1-.486-.486c.001-.284-.054-.464-.167-.54-.112-.076-.367-.106-.766-.091h-3.28l-2.68 10.257c-.006.074.007.127.038.158zM3 17h8a.5.5 0 1 1 0 1H3a.5.5 0 1 1 0-1zm11.299 1.17a.75.75 0 1 1-1.06-1.06l1.414-1.415-1.415-1.414a.75.75 0 0 1 1.06-1.06l1.415 1.414 1.414-1.415a.75.75 0 1 1 1.06 1.06l-1.413 1.415 1.414 1.415a.75.75 0 0 1-1.06 1.06l-1.415-1.414-1.414 1.414z"/></svg>

After

Width:  |  Height:  |  Size: 836 B

View File

@@ -0,0 +1 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="6.65" height="6.65" rx="1" fill="black"/><rect y="13" width="6.65" height="6.65" rx="1" fill="black"/><rect x="9" width="6.65" height="6.65" rx="1" fill="black"/><path d="M7.59998 16.3134L13.5799 12.8609L13.5799 19.7659L7.59998 16.3134Z" fill="black"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.7307 16.7783H17.13C18.2346 16.7783 19.13 15.8829 19.13 14.7783V4.84961C19.13 3.74504 18.2346 2.84961 17.13 2.84961H16.9706V3.84961H17.13C17.6823 3.84961 18.13 4.29732 18.13 4.84961V14.7783C18.13 15.3306 17.6823 15.7783 17.13 15.7783H12.7307V16.7783Z" fill="black"/></svg>

After

Width:  |  Height:  |  Size: 685 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m6.395 9.196 2.545-.007V6.498a.598.598 0 0 1 .598-.598h.299a.598.598 0 0 1 .598.598v6.877a.598.598 0 0 1-.598.598h-.299a.598.598 0 0 1-.598-.598v-2.691l-2.545.007v2.691a.598.598 0 0 1-.598.598h-.299a.598.598 0 0 1-.598-.598V6.505a.598.598 0 0 1 .598-.598h.299a.598.598 0 0 1 .598.598v2.691Z"/><path d="M15.094 13.417V6.462a.562.562 0 0 0-.562-.562h-.782a1 1 0 0 0-.39.08l-1.017.43a.562.562 0 0 0-.343.517v.197c0 .4.406.67.775.519l.819-.337v6.111c0 .31.251.562.561.562h.377c.31 0 .562-.251.562-.562Z"/><path d="M0 15.417v1.5h1.5v-1.5H0Z"/><path d="M18.5 15.417v1.5H20v-1.5h-1.5Z"/><path d="M18.5 12.333v1.5H20v-1.5h-1.5Z"/><path d="M18.5 9.25v1.5H20v-1.5h-1.5Z"/><path d="M18.5 6.167v1.5H20v-1.5h-1.5Z"/><path d="M0 18.5v.5a1 1 0 0 0 1 1h.5v-1.5H0Z"/><path d="M3.083 18.5V20h1.5v-1.5h-1.5Z"/><path d="M6.167 18.5V20h1.5v-1.5h-1.5Z"/><path d="M9.25 18.5V20h1.5v-1.5h-1.5Z"/><path d="M12.333 18.5V20h1.5v-1.5h-1.5Z"/><path d="M15.417 18.5V20h1.5v-1.5h-1.5Z"/><path d="M18.5 18.5V20h.5a1 1 0 0 0 1-1v-.5h-1.5Z"/><path clip-rule="evenodd" d="M0 1a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v3.583h-1.5V1.5h-17v12.333H0V1Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m12.5 0 5 4.5v15.003h-16V0h11zM3 1.5v3.25l-1.497 1-.003 8 1.5 1v3.254L7.685 18l-.001 1.504H17.5V8.002L16 9.428l-.004-4.22-4.222-3.692L3 1.5z"/><path d="M4.06 6.64a.75.75 0 0 1 .958 1.15l-.085.07L2.29 9.75l2.646 1.89c.302.216.4.62.232.951l-.058.095a.75.75 0 0 1-.951.232l-.095-.058-3.5-2.5V9.14l3.496-2.5zm4.194 6.22a.75.75 0 0 1-.958-1.149l.085-.07 2.643-1.89-2.646-1.89a.75.75 0 0 1-.232-.952l.058-.095a.75.75 0 0 1 .95-.232l.096.058 3.5 2.5v1.22l-3.496 2.5zm7.644-.836 2.122 2.122-5.825 5.809-2.125-.005.003-2.116zm2.539-1.847 1.414 1.414a.5.5 0 0 1 0 .707l-1.06 1.06-2.122-2.12 1.061-1.061a.5.5 0 0 1 .707 0z"/></svg>

After

Width:  |  Height:  |  Size: 689 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2.5a7.47 7.47 0 0 1 4.231 1.31 7.268 7.268 0 0 1 2.703 3.454 7.128 7.128 0 0 1 .199 4.353c-.39 1.436-1.475 2.72-2.633 3.677h2.013c0-.226.092-.443.254-.603a.876.876 0 0 1 1.229 0c.163.16.254.377.254.603v.853c0 .209-.078.41-.22.567a.873.873 0 0 1-.547.28l-.101.006h-4.695a.517.517 0 0 1-.516-.518v-1.265c0-.21.128-.398.317-.489a5.601 5.601 0 0 0 2.492-2.371 5.459 5.459 0 0 0 .552-3.693 5.53 5.53 0 0 0-1.955-3.2A5.71 5.71 0 0 0 10 4.206 5.708 5.708 0 0 0 6.419 5.46 5.527 5.527 0 0 0 4.46 8.663a5.457 5.457 0 0 0 .554 3.695 5.6 5.6 0 0 0 2.497 2.37.55.55 0 0 1 .317.49v1.264c0 .286-.23.518-.516.518H2.618a.877.877 0 0 1-.614-.25.845.845 0 0 1-.254-.603v-.853c0-.226.091-.443.254-.603a.876.876 0 0 1 1.228 0c.163.16.255.377.255.603h1.925c-1.158-.958-2.155-2.241-2.545-3.678a7.128 7.128 0 0 1 .199-4.352 7.268 7.268 0 0 1 2.703-3.455A7.475 7.475 0 0 1 10 2.5z"/></svg>

After

Width:  |  Height:  |  Size: 938 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 16.4c-.8-.4-1.5-.9-2.2-1.5a.6.6 0 0 1-.2-.5l.3-.6h1c1 1.2 2.1 1.7 3.7 1.7 1 0 1.8-.3 2.3-.6.6-.4.6-1.2.6-1.3.2-1.2-.9-2.1-.9-2.1h2.1c.3.7.4 1.2.4 1.7v.8l-.6 1.2c-.6.8-1.1 1-1.6 1.2a6 6 0 0 1-2.4.6c-1 0-1.8-.3-2.5-.6zM6.8 9 6 8.3c-.4-.5-.5-.8-.5-1.6 0-.7.1-1.3.5-1.8.4-.6 1-1 1.6-1.3a6.3 6.3 0 0 1 4.7 0 4 4 0 0 1 1.7 1l.3.7c0 .1.2.4-.2.7-.4.2-.9.1-1 0a3 3 0 0 0-1.2-1c-.4-.2-1-.3-2-.4-.7 0-1.4.2-2 .6-.8.6-1 .8-1 1.5 0 .8.5 1 1.2 1.5.6.4 1.1.7 1.9 1H6.8z"/><path d="M3 10.5V9h14v1.5z"/></svg>

After

Width:  |  Height:  |  Size: 565 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m7.03 10.349 3.818-3.819a.8.8 0 1 1 1.132 1.132L8.16 11.48l3.819 3.818a.8.8 0 1 1-1.132 1.132L7.03 12.61l-3.818 3.82a.8.8 0 1 1-1.132-1.132L5.9 11.48 2.08 7.662A.8.8 0 1 1 3.212 6.53l3.818 3.82zm8.147 7.829h2.549c.254 0 .447.05.58.152a.49.49 0 0 1 .201.413.54.54 0 0 1-.159.393c-.105.108-.266.162-.48.162h-3.594c-.245 0-.435-.066-.572-.197a.621.621 0 0 1-.205-.463c0-.114.044-.265.132-.453a1.62 1.62 0 0 1 .288-.444c.433-.436.824-.81 1.172-1.122.348-.312.597-.517.747-.615.267-.183.49-.368.667-.553.177-.185.312-.375.405-.57.093-.194.139-.384.139-.57a1.008 1.008 0 0 0-.554-.917 1.197 1.197 0 0 0-.56-.133c-.426 0-.761.182-1.005.546a2.332 2.332 0 0 0-.164.39 1.609 1.609 0 0 1-.258.488c-.096.114-.237.17-.423.17a.558.558 0 0 1-.405-.156.568.568 0 0 1-.161-.427c0-.218.05-.446.151-.683.101-.238.252-.453.452-.646s.454-.349.762-.467a2.998 2.998 0 0 1 1.081-.178c.498 0 .923.076 1.274.228a1.916 1.916 0 0 1 1.004 1.032 1.984 1.984 0 0 1-.156 1.794c-.2.32-.405.572-.613.754-.208.182-.558.468-1.048.857-.49.39-.826.691-1.008.906a2.703 2.703 0 0 0-.24.309z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M15.677 8.678h2.549c.254 0 .447.05.58.152a.49.49 0 0 1 .201.413.54.54 0 0 1-.159.393c-.105.108-.266.162-.48.162h-3.594c-.245 0-.435-.066-.572-.197a.621.621 0 0 1-.205-.463c0-.114.044-.265.132-.453a1.62 1.62 0 0 1 .288-.444c.433-.436.824-.81 1.172-1.122.348-.312.597-.517.747-.615.267-.183.49-.368.667-.553.177-.185.312-.375.405-.57.093-.194.139-.384.139-.57a1.008 1.008 0 0 0-.554-.917 1.197 1.197 0 0 0-.56-.133c-.426 0-.761.182-1.005.546a2.332 2.332 0 0 0-.164.39 1.609 1.609 0 0 1-.258.488c-.096.114-.237.17-.423.17a.558.558 0 0 1-.405-.156.568.568 0 0 1-.161-.427c0-.218.05-.446.151-.683.101-.238.252-.453.452-.646s.454-.349.762-.467a2.998 2.998 0 0 1 1.081-.178c.498 0 .923.076 1.274.228a1.916 1.916 0 0 1 1.004 1.032 1.984 1.984 0 0 1-.156 1.794c-.2.32-.405.572-.613.754-.208.182-.558.468-1.048.857-.49.39-.826.691-1.008.906a2.703 2.703 0 0 0-.24.309zM7.03 10.349l3.818-3.819a.8.8 0 1 1 1.132 1.132L8.16 11.48l3.819 3.818a.8.8 0 1 1-1.132 1.132L7.03 12.61l-3.818 3.82a.8.8 0 1 1-1.132-1.132L5.9 11.48 2.08 7.662A.8.8 0 1 1 3.212 6.53l3.818 3.82z"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 6v3h4V6H3zm0 4v3h4v-3H3zm0 4v3h4v-3H3zm5 3h4v-3H8v3zm5 0h4v-3h-4v3zm4-4v-3h-4v3h4zm0-4V6h-4v3h4zm1.5 8a1.5 1.5 0 0 1-1.5 1.5H3A1.5 1.5 0 0 1 1.5 17V4c.222-.863 1.068-1.5 2-1.5h13c.932 0 1.778.637 2 1.5v13zM12 13v-3H8v3h4zm0-4V6H8v3h4z"/></svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 18v-1.5h14V18zM5.2 10V3.6c0-.4.4-.6.8-.6.3 0 .7.2.7.6v6.2c0 2 1.3 2.8 3.2 2.8 1.9 0 3.4-.9 3.4-2.9V3.6c0-.3.4-.5.8-.5.3 0 .7.2.7.5V10c0 2.7-2.2 4-4.9 4-2.6 0-4.7-1.2-4.7-4z"></path></svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m5.042 9.367 2.189 1.837a.75.75 0 0 1-.965 1.149l-3.788-3.18a.747.747 0 0 1-.21-.284.75.75 0 0 1 .17-.945L6.23 4.762a.75.75 0 1 1 .964 1.15L4.863 7.866h8.917A.75.75 0 0 1 14 7.9a4 4 0 1 1-1.477 7.718l.344-1.489a2.5 2.5 0 1 0 1.094-4.73l.008-.032H5.042z"/></svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1 @@
<svg fill="none" height="16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#ffd23f"><path d="M6.5 1h3v9h-3z"/><circle cx="8" cy="13.5" r="1.5"/></g></svg>

After

Width:  |  Height:  |  Size: 164 B

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.drupalEmphasis=t())}(globalThis,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,r)=>{e.exports=r("dll-reference CKEditor5.dll")("./src/core.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function r(o){var i=t[o];if(void 0!==i)return i.exports;var s=t[o]={exports:{}};return e[o](s,s.exports,r),s.exports}r.d=(e,t)=>{for(var o in t)r.o(t,o)&&!r.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var o={};return(()=>{"use strict";r.d(o,{default:()=>n});var e=r("ckeditor5/src/core.js");class t extends e.Plugin{static get pluginName(){return"DrupalEmphasisEditing"}init(){this.editor.conversion.for("downcast").attributeToElement({model:"italic",view:"em",converterPriority:"high"})}}const i=t;class s extends e.Plugin{static get requires(){return[i]}static get pluginName(){return"DrupalEmphasis"}}const n={DrupalEmphasis:s}})(),o=o.default})()));

View File

@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.drupalHtmlEngine=t())}(globalThis,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,n)=>{e.exports=n("dll-reference CKEditor5.dll")("./src/core.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function n(p){var r=t[p];if(void 0!==r)return r.exports;var s=t[p]={exports:{}};return e[p](s,s.exports,n),s.exports}n.d=(e,t)=>{for(var p in t)n.o(t,p)&&!n.o(e,p)&&Object.defineProperty(e,p,{enumerable:!0,get:t[p]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var p={};return(()=>{"use strict";n.d(p,{default:()=>a});var e=n("ckeditor5/src/core.js");class t{constructor(){this.chunks=[],this.selfClosingTags=["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"],this.rawTags=["script","style"]}build(){return this.chunks.join("")}appendNode(e){e.nodeType===Node.TEXT_NODE?this._appendText(e):e.nodeType===Node.ELEMENT_NODE?this._appendElement(e):e.nodeType===Node.DOCUMENT_FRAGMENT_NODE?this._appendChildren(e):e.nodeType===Node.COMMENT_NODE&&this._appendComment(e)}_appendElement(e){const t=e.nodeName.toLowerCase();this._append("<"),this._append(t),this._appendAttributes(e),this._append(">"),this.selfClosingTags.includes(t)||(this._appendChildren(e),this._append("</"),this._append(t),this._append(">"))}_appendChildren(e){Object.keys(e.childNodes).forEach((t=>{this.appendNode(e.childNodes[t])}))}_appendAttributes(e){Object.keys(e.attributes).forEach((t=>{this._append(" "),this._append(e.attributes[t].name),this._append('="'),this._append(this.constructor._escapeAttribute(e.attributes[t].value)),this._append('"')}))}_appendText(e){const t=document.implementation.createHTMLDocument("").createElement("p");t.textContent=e.textContent,e.parentElement&&this.rawTags.includes(e.parentElement.tagName.toLowerCase())?this._append(t.textContent):this._append(t.innerHTML)}_appendComment(e){this._append("\x3c!--"),this._append(e.textContent),this._append("--\x3e")}_append(e){this.chunks.push(e)}static _escapeAttribute(e){return e.replace(/&/g,"&amp;").replace(/'/g,"&apos;").replace(/"/g,"&quot;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/\r\n/g,"&#13;").replace(/[\r\n]/g,"&#13;")}}class r{getHtml(e){const n=new t;return n.appendNode(e),n.build()}}class s extends e.Plugin{init(){this.editor.data.processor.htmlWriter=new r}static get pluginName(){return"DrupalHtmlEngine"}}const a={DrupalHtmlEngine:s}})(),p=p.default})()));

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
/**
* @file
* This file overrides the way jQuery UI focus trap works.
*
* When a focus event is fired while a CKEditor 5 instance is focused, do not
* trap the focus and let CKEditor 5 manage that focus.
*/
(($) => {
$.widget('ui.dialog', $.ui.dialog, {
// Override core override of jQuery UI's `_allowInteraction()` so that
// CKEditor 5 in modals can work as expected.
// @see https://api.jqueryui.com/dialog/#method-_allowInteraction
_allowInteraction(event) {
// Fixes "Uncaught TypeError: event.target.classList is undefined"
// in Firefox (only).
// @see https://www.drupal.org/project/drupal/issues/3351600
if (event.target.classList === undefined) {
return this._super(event);
}
return event.target.classList.contains('ck') || this._super(event);
},
});
})(jQuery);

View File

@@ -0,0 +1,74 @@
/**
* @file
* Provides Text Editor UI improvements specific to CKEditor 5.
*/
((Drupal, once) => {
Drupal.behaviors.allowedTagsListener = {
attach: function attach(context) {
once(
'ajax-conflict-prevention',
'[data-drupal-selector="filter-format-edit-form"], [data-drupal-selector="filter-format-add-form"]',
context,
).forEach((form) => {
// When the form is submitted, remove the disabled attribute from all
// AJAX enabled form elements. The disabled state is added as part of
// AJAX processing, but will prevent the value from being added to
// $form_state.
form.addEventListener('submit', () => {
once
.filter(
'drupal-ajax',
'[data-drupal-selector="filter-format-edit-form"] [disabled], [data-drupal-selector="filter-format-add-form"] [disabled]',
)
// eslint-disable-next-line max-nested-callbacks
.forEach((disabledElement) => {
disabledElement.removeAttribute('disabled');
});
});
});
},
};
// Copy the function that is about to be overridden so it can be invoked
// inside the override.
const originalAjaxEventResponse = Drupal.Ajax.prototype.eventResponse;
/**
* Overrides Ajax.eventResponse with CKEditor 5 specific customizations.
*
* This is the handler for events that will ultimately trigger an AJAX
* response. It is overridden here to provide additional logic to prevent
* specific CKEditor 5-related events from triggering that AJAX response
* unless certain criteria are met.
*/
Drupal.Ajax.prototype.eventResponse = function ckeditor5AjaxEventResponse(
...args
) {
// There are AJAX callbacks that should only be triggered if the editor
// <select> is set to 'ckeditor5'. They should be active when the text
// format is using CKEditor 5 and when a user is attempting to switch to
// CKEditor 5 but is prevented from doing so by validation. Triggering these
// AJAX callback when trying to switch to CKEditor 5 but blocked by
// validation benefits the user as they get real time feedback as they
// configure the text format to be CKEditor 5 compatible. This spares them
// from having to submit the form multiple times in order to determine if
// their settings are compatible.
// This validation stage is also why the AJAX callbacks can't be
// conditionally added server side, as validation errors interrupt the form
// rebuild before the AJAX callbacks could be added via form_alter.
if (this.ckeditor5_only) {
// The ckeditor5_only property is added to form elements that should only
// trigger AJAX callbacks when the editor <select> value is 'ckeditor5'.
// These callbacks provide real-time validation that should be present for
// both text formats using CKEditor 5 and text formats in the process of
// switching to CKEditor 5, but prevented from doing so by validation.
if (
this.$form[0].querySelector('#edit-editor-editor').value !== 'ckeditor5'
) {
return;
}
}
originalAjaxEventResponse.apply(this, args);
};
})(Drupal, once);

View File

@@ -0,0 +1,30 @@
/**
* @file
* CKEditor 5 Image admin behavior.
*/
(function ($, Drupal) {
/**
* Provides the summary for the "image" plugin settings vertical tab.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches summary behavior to the plugin settings vertical tab.
*/
Drupal.behaviors.ckeditor5ImageSettingsSummary = {
attach() {
$('[data-ckeditor5-plugin-id="ckeditor5_image"]').drupalSetSummary(
(context) => {
const uploadsEnabled = document.querySelector(
'[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-image-status"]',
).checked;
if (uploadsEnabled) {
return Drupal.t('Images can only be uploaded.');
}
return Drupal.t('Images can only be added by URL.');
},
);
},
};
})(jQuery, Drupal);

View File

@@ -0,0 +1,681 @@
/**
* @file
* CKEditor 5 implementation of {@link Drupal.editors} API.
*/
((Drupal, debounce, CKEditor5, $, once) => {
/**
* The CKEditor 5 instances.
*
* @type {Map}
*/
Drupal.CKEditor5Instances = new Map();
/**
* The callback functions.
*
* @type {Map}
*/
const callbacks = new Map();
/**
* List of element ids with the required attribute.
*
* @type {Set}
*/
const required = new Set();
/**
* Get the value of the (deep) property on name from scope.
*
* @param {object} scope
* Object used to search for the function.
* @param {string} name
* The path to access in the scope object.
*
* @return {null|function}
* The corresponding function from the scope object.
*/
function findFunc(scope, name) {
if (!scope) {
return null;
}
const parts = name.includes('.') ? name.split('.') : name;
if (parts.length > 1) {
return findFunc(scope[parts.shift()], parts);
}
return typeof scope[parts[0]] === 'function' ? scope[parts[0]] : null;
}
/**
* Transform a config key in a callback function or execute the function
* to dynamically build the configuration entry.
*
* @param {object} config
* The plugin configuration object.
*
* @return {null|function|*}
* Resulting configuration value.
*/
function buildFunc(config) {
const { func } = config;
// Assuming a global object.
const fn = findFunc(window, func.name);
if (typeof fn === 'function') {
const result = func.invoke ? fn(...func.args) : fn;
return result;
}
return null;
}
/**
* Converts a string representing regexp to a RegExp object.
*
* @param {Object} config
* An object containing configuration.
* @param {string} config.pattern
* The regexp pattern that is used to create the RegExp object.
*
* @return {RegExp}
* Regexp object built from the string regexp.
*/
function buildRegexp(config) {
const { pattern } = config.regexp;
const main = pattern.match(/\/(.+)\/.*/)[1];
const options = pattern.match(/\/.+\/(.*)/)[1];
return new RegExp(main, options);
}
/**
* Casts configuration items to correct types.
*
* @param {Object} config
* The config object.
* @return {Object}
* The config object with items transformed to correct type.
*/
function processConfig(config) {
/**
* Processes an array in config recursively.
*
* @param {Array} config
* An array that should be processed recursively.
* @return {Array}
* An array that has been processed recursively.
*/
function processArray(config) {
return config.map((item) => {
if (typeof item === 'object') {
return processConfig(item);
}
return item;
});
}
if (config === null) {
return null;
}
return Object.entries(config).reduce((processed, [key, value]) => {
if (typeof value === 'object') {
// Check for null values.
if (!value) {
return processed;
}
if (value.hasOwnProperty('func')) {
processed[key] = buildFunc(value);
} else if (value.hasOwnProperty('regexp')) {
processed[key] = buildRegexp(value);
} else if (Array.isArray(value)) {
processed[key] = processArray(value);
} else {
processed[key] = processConfig(value);
}
} else {
processed[key] = value;
}
return processed;
}, {});
}
/**
* Set an id to a data-attribute for registering this element instance.
*
* @param {Element} element
* An element that should receive unique ID.
*
* @return {string}
* The id to use for this element.
*/
const setElementId = (element) => {
const id = Math.random().toString().slice(2, 9);
element.setAttribute('data-ckeditor5-id', id);
return id;
};
/**
* Return a unique selector for the element.
*
* @param {HTMLElement} element
* An element which unique ID should be retrieved.
*
* @return {string}
* The id to use for this element.
*/
const getElementId = (element) => element.getAttribute('data-ckeditor5-id');
/**
* Select CKEditor 5 plugin classes to include.
*
* Found in the CKEditor 5 global JavaScript object as {package.Class}.
*
* @param {Array} plugins
* List of package and Class name of plugins
*
* @return {Array}
* List of JavaScript Classes to add in the extraPlugins property of config.
*/
function selectPlugins(plugins) {
return plugins.map((pluginDefinition) => {
const [build, name] = pluginDefinition.split('.');
if (CKEditor5[build] && CKEditor5[build][name]) {
return CKEditor5[build][name];
}
// eslint-disable-next-line no-console
console.warn(`Failed to load ${build} - ${name}`);
return null;
});
}
/**
* Process a group of CSS rules.
*
* @param {CSSGroupingRule} rulesGroup
* A complete stylesheet or a group of nested rules like @media.
*/
function processRules(rulesGroup) {
try {
// eslint-disable-next-line no-use-before-define
[...rulesGroup.cssRules].forEach(ckeditor5SelectorProcessing);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(
`Stylesheet ${rulesGroup.href} not included in CKEditor reset due to the browser's CORS policy.`,
);
}
}
/**
* Processes CSS rules dynamically to account for CKEditor 5 in off canvas.
*
* This is achieved by doing the following steps:
* - Adding a donut scope to off canvas rules, so they don't apply within the
* editor element.
* - Editor specific rules (i.e. those with .ck* selectors) are duplicated and
* prefixed with the off canvas selector to ensure they have higher
* specificity over the off canvas reset.
*
* The donut scope prevents off canvas rules from applying to the CKEditor 5
* editor element. Transforms a:
* - #drupal-off-canvas strong
* rule into:
* - #drupal-off-canvas strong:not([data-drupal-ck-style-fence] *)
*
* This means that the rule applies to all <strong> elements inside
* #drupal-off-canvas, except for <strong> elements who have a with a parent
* with the "data-drupal-ck-style-fence" attribute.
*
* For example:
* <div id="drupal-off-canvas">
* <p>
* <strong>Off canvas reset</strong>
* </p>
* <p data-drupal-ck-style-fence>
* <!--
* this strong elements matches the `[data-drupal-ck-style-fence] *`
* selector and is excluded from the off canvas reset rule.
* -->
* <strong>Off canvas reset NOT applied.</strong>
* </p>
* </div>
*
* The donut scope does not prevent CSS inheritance. There is CSS that resets
* following properties to prevent inheritance: background, border,
* box-sizing, margin, padding, position, text-decoration, transition,
* vertical-align and word-wrap.
*
* All .ck* CSS rules are duplicated and prefixed with the off canvas selector
* To ensure they have higher specificity and are not reset too aggressively.
*
* @param {CSSRule} rule
* A single CSS rule to be analyzed and changed if necessary.
*/
function ckeditor5SelectorProcessing(rule) {
// Handle nested rules in @media, @support, etc.
if (rule.cssRules) {
processRules(rule);
}
if (!rule.selectorText) {
return;
}
const offCanvasId = '#drupal-off-canvas';
const CKEditorClass = '.ck';
const styleFence = '[data-drupal-ck-style-fence]';
if (
rule.selectorText.includes(offCanvasId) ||
rule.selectorText.includes(CKEditorClass)
) {
rule.selectorText = rule.selectorText
.split(/,/g)
.map((selector) => {
// Only change rules that include #drupal-off-canvas in the selector.
if (selector.includes(offCanvasId)) {
return `${selector.trim()}:not(${styleFence} *)`;
}
// Duplicate CKEditor 5 styles with higher specificity for proper
// display in off canvas elements.
if (selector.includes(CKEditorClass)) {
// Return both rules to avoid replacing the existing rules.
return [
selector.trim(),
selector
.trim()
.replace(
CKEditorClass,
`${offCanvasId} ${styleFence} ${CKEditorClass}`,
),
];
}
return selector;
})
.flat()
.join(', ');
}
}
/**
* Adds CSS to ensure proper styling of CKEditor 5 inside off-canvas dialogs.
*
* @param {HTMLElement} element
* The element the editor is attached to.
*/
function offCanvasCss(element) {
const fenceName = 'data-drupal-ck-style-fence';
const editor = Drupal.CKEditor5Instances.get(
element.getAttribute('data-ckeditor5-id'),
);
editor.ui.view.element.setAttribute(fenceName, '');
// Only proceed if the styles haven't been added yet.
if (once('ckeditor5-off-canvas-reset', 'body').length) {
// For all rules on the page, add the donut scope for
// rules containing the #drupal-off-canvas selector.
[...document.styleSheets].forEach(processRules);
const prefix = `#drupal-off-canvas-wrapper [${fenceName}]`;
// Additional styles that need to be explicity added in addition to the
// prefixed versions of existing css in `existingCss`.
const addedCss = [
`${prefix} .ck.ck-content {display:block;min-height:5rem;}`,
`${prefix} .ck.ck-content * {display:revert;background:revert;color:initial;padding:revert;}`,
`${prefix} .ck.ck-content li {display:list-item}`,
`${prefix} .ck.ck-content ol li {list-style-type: decimal}`,
];
const prefixedCss = [...addedCss].join('\n');
// Create a new style tag with the prefixed styles added above.
const offCanvasCssStyle = document.createElement('style');
offCanvasCssStyle.textContent = prefixedCss;
offCanvasCssStyle.setAttribute('id', 'ckeditor5-off-canvas-reset');
document.body.appendChild(offCanvasCssStyle);
}
}
/**
* Integration of CKEditor 5 with the Drupal editor API.
*
* @namespace
*
* @see Drupal.editorAttach
*/
Drupal.editors.ckeditor5 = {
/**
* Editor attach callback.
*
* @param {HTMLElement} element
* The element to attach the editor to.
* @param {string} format
* The text format for the editor.
*/
attach(element, format) {
const { editorClassic } = CKEditor5;
const { toolbar, plugins, config, language } = format.editorSettings;
const extraPlugins = selectPlugins(plugins);
const pluginConfig = processConfig(config);
const editorConfig = {
extraPlugins,
toolbar,
...pluginConfig,
// Language settings have a conflict between the editor localization
// settings and the "language" plugin.
language: { ...pluginConfig.language, ...language },
};
// Set the id immediately so that it is available when onChange is called.
const id = setElementId(element);
const { ClassicEditor } = editorClassic;
ClassicEditor.create(element, editorConfig)
.then((editor) => {
/**
* Injects a temporary <p> into CKEditor and then calculates the entire
* height of the amount of the <p> tags from the passed in rows value.
*
* This takes into account collapsing margins, and line-height of the
* current theme.
*
* @param {number} - the number of rows.
*
* @returns {number} - the height of a div in pixels.
*/
function calculateLineHeight(rows) {
const element = document.createElement('p');
element.setAttribute('style', 'visibility: hidden;');
element.innerHTML = '&nbsp;';
editor.ui.view.editable.element.append(element);
const styles = window.getComputedStyle(element);
const height = element.clientHeight;
const marginTop = parseInt(styles.marginTop, 10);
const marginBottom = parseInt(styles.marginBottom, 10);
const mostMargin =
marginTop >= marginBottom ? marginTop : marginBottom;
element.remove();
return (
(height + mostMargin) * (rows - 1) +
marginTop +
height +
marginBottom
);
}
// Save a reference to the initialized instance.
Drupal.CKEditor5Instances.set(id, editor);
// Set the minimum height of the editable area to correspond with the
// value of the number of rows. We attach this custom property to
// the `.ck-editor` element, as that doesn't get its inline styles
// cleared on focus. The editable element is then set to use this
// property within the stylesheet.
const rows = editor.sourceElement.getAttribute('rows');
editor.ui.view.editable.element
.closest('.ck-editor')
.style.setProperty(
'--ck-min-height',
`${calculateLineHeight(rows)}px`,
);
// CKEditor 4 had a feature to remove the required attribute
// see: https://www.drupal.org/project/drupal/issues/1954968
if (element.hasAttribute('required')) {
required.add(id);
element.removeAttribute('required');
}
// If the textarea is disabled, enable CKEditor's read-only mode.
if (element.hasAttribute('disabled')) {
editor.enableReadOnlyMode('ckeditor5_disabled');
}
// Integrate CKEditor 5 viewport offset with Drupal displace.
// @see \Drupal\Tests\ckeditor5\FunctionalJavascript\CKEditor5ToolbarTest
// @see https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editorui-EditorUI.html#member-viewportOffset
$(document).on(
`drupalViewportOffsetChange.ckeditor5.${id}`,
(event, offsets) => {
editor.ui.viewportOffset = offsets;
},
);
editor.model.document.on('change:data', () => {
const callback = callbacks.get(id);
if (callback) {
// Marks the field as changed.
// @see Drupal.editorAttach
callback();
}
});
const isOffCanvas = element.closest('#drupal-off-canvas');
if (isOffCanvas) {
offCanvasCss(element);
}
})
.catch((error) => {
// eslint-disable-next-line no-console
console.info(
'Debugging can be done with an unminified version of CKEditor by installing from the source file. Consult documentation at https://www.drupal.org/node/3258901',
);
// eslint-disable-next-line no-console
console.error(error);
});
},
/**
* Editor detach callback.
*
* @param {HTMLElement} element
* The element to detach the editor from.
* @param {string} format
* The text format used for the editor.
* @param {string} trigger
* The event trigger for the detach.
*/
detach(element, format, trigger) {
const id = getElementId(element);
const editor = Drupal.CKEditor5Instances.get(id);
if (!editor) {
return;
}
$(document).off(`drupalViewportOffsetChange.ckeditor5.${id}`);
if (trigger === 'serialize') {
editor.updateSourceElement();
} else {
element.removeAttribute('contentEditable');
// Return the promise to allow external code to queue code to
// execute after the destroy is complete.
return editor
.destroy()
.then(() => {
// Clean up stored references.
Drupal.CKEditor5Instances.delete(id);
callbacks.delete(id);
if (required.has(id)) {
element.setAttribute('required', 'required');
required.delete(id);
}
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
});
}
},
/**
* Registers a callback which CKEditor 5 will call on change:data event.
*
* @param {HTMLElement} element
* The element where the change occurred.
* @param {function} callback
* Callback called with the value of the editor.
*/
onChange(element, callback) {
callbacks.set(getElementId(element), debounce(callback, 400, true));
},
/**
* Attaches an inline editor to a DOM element.
*
* @param {HTMLElement} element
* The element to attach the editor to.
* @param {object} format
* The text format used in the editor.
* @param {string} [mainToolbarId]
* The id attribute for the main editor toolbar, if any.
*/
attachInlineEditor(element, format, mainToolbarId) {
const { editorDecoupled } = CKEditor5;
const {
toolbar,
plugins,
config: pluginConfig,
language,
} = format.editorSettings;
const extraPlugins = selectPlugins(plugins);
const config = {
extraPlugins,
toolbar,
language,
...processConfig(pluginConfig),
};
const id = setElementId(element);
const { DecoupledEditor } = editorDecoupled;
DecoupledEditor.create(element, config)
.then((editor) => {
Drupal.CKEditor5Instances.set(id, editor);
const toolbar = document.getElementById(mainToolbarId);
toolbar.appendChild(editor.ui.view.toolbar.element);
editor.model.document.on('change:data', () => {
const callback = callbacks.get(id);
if (callback) {
// Allow modules to update EditorModel by providing the current data.
callback(editor.getData());
}
});
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
});
},
};
/**
* Public API for Drupal CKEditor 5 integration.
*
* @namespace
*/
Drupal.ckeditor5 = {
/**
* Variable storing the current dialog's save callback.
*
* @type {?function}
*/
saveCallback: null,
/**
* Open a dialog for a Drupal-based plugin.
*
* This dynamically loads jQuery UI (if necessary) using the Drupal AJAX
* framework, then opens a dialog at the specified Drupal path.
*
* @param {string} url
* The URL that contains the contents of the dialog.
* @param {function} saveCallback
* A function to be called upon saving the dialog.
* @param {object} dialogSettings
* An object containing settings to be passed to the jQuery UI.
*/
openDialog(url, saveCallback, dialogSettings) {
// Add a consistent dialog class.
const classes = dialogSettings.dialogClass
? dialogSettings.dialogClass.split(' ')
: [];
classes.push('ui-dialog--narrow');
dialogSettings.dialogClass = classes.join(' ');
dialogSettings.autoResize =
window.matchMedia('(min-width: 600px)').matches;
dialogSettings.width = 'auto';
const ckeditorAjaxDialog = Drupal.ajax({
dialog: dialogSettings,
dialogType: 'modal',
selector: '.ckeditor5-dialog-loading-link',
url,
progress: { type: 'fullscreen' },
submit: {
editor_object: {},
},
});
ckeditorAjaxDialog.execute();
// Store the save callback to be executed when this dialog is closed.
Drupal.ckeditor5.saveCallback = saveCallback;
},
};
// Redirect on hash change when the original hash has an associated CKEditor 5.
function redirectTextareaFragmentToCKEditor5Instance() {
const hash = window.location.hash.substring(1);
const element = document.getElementById(hash);
if (element) {
const editorID = getElementId(element);
const editor = Drupal.CKEditor5Instances.get(editorID);
if (editor) {
// Give the CKEditor 5 instance an ID.
editor.sourceElement.nextElementSibling.setAttribute(
'id',
`cke_${hash}`,
);
window.location.replace(`#cke_${hash}`);
}
}
}
$(window).on(
'hashchange.ckeditor',
redirectTextareaFragmentToCKEditor5Instance,
);
// Respond to new dialogs that are opened by CKEditor, closing the AJAX loader.
window.addEventListener('dialog:beforecreate', () => {
const dialogLoading = document.querySelector('.ckeditor5-dialog-loading');
if (dialogLoading) {
dialogLoading.addEventListener(
'transitionend',
function removeDialogLoading() {
dialogLoading.remove();
},
);
dialogLoading.style.transition = 'top 0.5s ease';
dialogLoading.style.top = '-40px';
}
});
// Respond to dialogs that are saved, sending data back to CKEditor.
$(window).on('editor:dialogsave', (e, values) => {
if (Drupal.ckeditor5.saveCallback) {
Drupal.ckeditor5.saveCallback(values);
}
});
// Respond to dialogs that are closed, removing the current save handler.
window.addEventListener('dialog:afterclose', () => {
if (Drupal.ckeditor5.saveCallback) {
Drupal.ckeditor5.saveCallback = null;
}
});
})(Drupal, Drupal.debounce, CKEditor5, jQuery, once);

View File

@@ -0,0 +1,39 @@
/**
* @file
* CKEditor 5 Style admin behavior.
*/
(function ($, Drupal) {
/**
* Provides the summary for the "style" plugin settings vertical tab.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches summary behavior to the plugin settings vertical tab.
*/
Drupal.behaviors.ckeditor5StyleSettingsSummary = {
attach() {
$('[data-ckeditor5-plugin-id="ckeditor5_style"]').drupalSetSummary(
(context) => {
const stylesElement = document.querySelector(
'[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]',
);
const styleCount = stylesElement.value
.split('\n')
// Minimum length is 5: "p.z|Z" is the shortest possible style definition.
.filter((line) => line.trim().length >= 5).length;
if (styleCount === 0) {
return Drupal.t('No styles configured');
}
return Drupal.formatPlural(
styleCount,
'One style configured',
'@count styles configured',
);
},
);
},
};
})(jQuery, Drupal);

View File

@@ -0,0 +1,28 @@
/* eslint-disable import/no-extraneous-dependencies */
// cspell:ignore drupalemphasisediting
import { Plugin } from 'ckeditor5/src/core';
import DrupalEmphasisEditing from './drupalemphasisediting';
/**
* Drupal-specific plugin to alter the CKEditor 5 italic command.
*
* @private
*/
class DrupalEmphasis extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [DrupalEmphasisEditing];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalEmphasis';
}
}
export default DrupalEmphasis;

View File

@@ -0,0 +1,29 @@
/* eslint-disable import/no-extraneous-dependencies */
import { Plugin } from 'ckeditor5/src/core';
/**
* Alters the italic command to output `<em>` instead of `<i>`.
*
* @private
*/
class DrupalEmphasisEditing extends Plugin {
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalEmphasisEditing';
}
/**
* @inheritdoc
*/
init() {
this.editor.conversion.for('downcast').attributeToElement({
model: 'italic',
view: 'em',
converterPriority: 'high',
});
}
}
export default DrupalEmphasisEditing;

View File

@@ -0,0 +1,10 @@
// cspell:ignore drupalemphasis
import DrupalEmphasis from './drupalemphasis';
/**
* @private
*/
export default {
DrupalEmphasis,
};

View File

@@ -0,0 +1,215 @@
// cspell:ignore apos
/**
* HTML builder that converts document fragments into strings.
*
* Escapes ampersand characters (`&`) and angle brackets (`<` and `>`) when
* transforming data to HTML. This is required because
* \Drupal\Component\Utility\Xss::filter fails to parse element attributes
* values containing unescaped HTML entities.
*
* @see https://www.drupal.org/project/drupal/issues/3227831
* @see DrupalHtmlBuilder._escapeAttribute
*
* @private
*/
export default class DrupalHtmlBuilder {
/**
* Constructs a new object.
*/
constructor() {
this.chunks = [];
// @see https://html.spec.whatwg.org/multipage/syntax.html#elements-2
this.selfClosingTags = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
];
// @see https://html.spec.whatwg.org/multipage/syntax.html#raw-text-elements
this.rawTags = ['script', 'style'];
}
/**
* Returns the current HTML string built from document fragments.
*
* @return {string}
* The HTML string built from document fragments.
*/
build() {
return this.chunks.join('');
}
/**
* Converts a document fragment into an HTML string appended to the value.
*
* @param {DocumentFragment} node
* A document fragment to be appended to the value.
*/
appendNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
this._appendText(node);
} else if (node.nodeType === Node.ELEMENT_NODE) {
this._appendElement(node);
} else if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
this._appendChildren(node);
} else if (node.nodeType === Node.COMMENT_NODE) {
this._appendComment(node);
}
}
/**
* Appends an element node to the value.
*
* @param {DocumentFragment} node
* A document fragment to be appended to the value.
*
* @private
*/
_appendElement(node) {
const nodeName = node.nodeName.toLowerCase();
this._append('<');
this._append(nodeName);
this._appendAttributes(node);
this._append('>');
if (!this.selfClosingTags.includes(nodeName)) {
this._appendChildren(node);
this._append('</');
this._append(nodeName);
this._append('>');
}
}
/**
* Appends child nodes to the value.
*
* @param {DocumentFragment} node
* A document fragment to be appended to the value.
*
* @private
*/
_appendChildren(node) {
Object.keys(node.childNodes).forEach((child) => {
this.appendNode(node.childNodes[child]);
});
}
/**
* Appends attributes to the value.
*
* @param {DocumentFragment} node
* A document fragment to be appended to the value.
*
* @private
*/
_appendAttributes(node) {
Object.keys(node.attributes).forEach((attr) => {
this._append(' ');
this._append(node.attributes[attr].name);
this._append('="');
this._append(
this.constructor._escapeAttribute(node.attributes[attr].value),
);
this._append('"');
});
}
/**
* Appends text to the value.
*
* @param {DocumentFragment} node
* A document fragment to be appended to the value.
*
* @private
*/
_appendText(node) {
// Repack the text into another node and extract using innerHTML. This
// works around text nodes not having an innerHTML property and textContent
// not encoding entities.
// entities. That's why the text is repacked into another node and extracted
// using innerHTML.
const doc = document.implementation.createHTMLDocument('');
const container = doc.createElement('p');
container.textContent = node.textContent;
if (
node.parentElement &&
this.rawTags.includes(node.parentElement.tagName.toLowerCase())
) {
this._append(container.textContent);
} else {
this._append(container.innerHTML);
}
}
/**
* Appends a comment to the value.
*
* @param {DocumentFragment} node
* A document fragment to be appended to the value.
*
* @private
*/
_appendComment(node) {
this._append('<!--');
this._append(node.textContent);
this._append('-->');
}
/**
* Appends a string to the value.
*
* @param {string} str
* A string to be appended to the value.
*
* @private
*/
_append(str) {
this.chunks.push(str);
}
/**
* Escapes attribute value for compatibility with Drupal's XSS filtering.
*
* Drupal's XSS filtering cannot handle entities inside element attribute
* values. The XSS filtering was written based on W3C XML recommendations
* which constituted that the ampersand character (&) and the angle
* brackets (< and >) must not appear in their literal form in attribute
* values. This differs from the HTML living standard which permits angle
* brackets.
*
* @param {string} text
* A string to be escaped.
*
* @return {string}
* Escaped string.
*
* @see https://www.w3.org/TR/2008/REC-xml-20081126/#NT-AttValue
* @see https://html.spec.whatwg.org/multipage/parsing.html#attribute-value-(single-quoted)-state
* @see https://www.drupal.org/project/drupal/issues/3227831
*
* @private
*/
static _escapeAttribute(text) {
return text
.replace(/&/g, '&amp;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\r\n/g, '&#13;')
.replace(/[\r\n]/g, '&#13;');
}
}

View File

@@ -0,0 +1,33 @@
/* eslint-disable import/no-extraneous-dependencies */
// cspell:ignore drupalhtmlwriter
import { Plugin } from 'ckeditor5/src/core';
import DrupalHtmlWriter from './drupalhtmlwriter';
/**
* A plugin that overrides the CKEditor HTML writer.
*
* Overrides the CKEditor 5 HTML writer to account for Drupal XSS filtering
* needs.
*
* @see https://www.drupal.org/project/drupal/issues/3227831
* @see DrupalHtmlBuilder._escapeAttribute
*
* @private
*/
class DrupalHtmlEngine extends Plugin {
/**
* @inheritdoc
*/
init() {
this.editor.data.processor.htmlWriter = new DrupalHtmlWriter();
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalHtmlEngine';
}
}
export default DrupalHtmlEngine;

View File

@@ -0,0 +1,31 @@
// cspell:ignore drupalhtmlbuilder dataprocessor basichtmlwriter htmlwriter
import DrupalHtmlBuilder from './drupalhtmlbuilder';
/**
* Custom HTML writer. It creates HTML by traversing DOM nodes.
*
* It differs to BasicHtmlWriter in the way it encodes entities in element
* attributes.
*
* @see module:engine/dataprocessor/basichtmlwriter~BasicHtmlWriter
* @implements module:engine/dataprocessor/htmlwriter~HtmlWriter
*
* @see https://www.drupal.org/project/drupal/issues/3227831
*
* @private
*/
export default class DrupalHtmlWriter {
/**
* Returns an HTML string created from the document fragment.
*
* @param {DocumentFragment} fragment
* @return {String}
*/
// eslint-disable-next-line class-methods-use-this
getHtml(fragment) {
const builder = new DrupalHtmlBuilder();
builder.appendNode(fragment);
return builder.build();
}
}

View File

@@ -0,0 +1,9 @@
// cspell:ignore drupalhtmlengine
import DrupalHtmlEngine from './drupalhtmlengine';
/**
* @private
*/
export default {
DrupalHtmlEngine,
};

View File

@@ -0,0 +1,27 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalimageediting drupalimagealternativetext */
import { Plugin } from 'ckeditor5/src/core';
import DrupalImageEditing from './drupalimageediting';
import DrupalImageAlternativeText from './drupalimagealternativetext';
/**
* @private
*/
class DrupalImage extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [DrupalImageEditing, DrupalImageAlternativeText];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalImage';
}
}
export default DrupalImage;

View File

@@ -0,0 +1,40 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore imagealternativetext imagetextalternativeediting drupalimagealternativetextediting drupalimagealternativetextui */
/**
* @module drupalImage/imagealternativetext
*/
import { Plugin } from 'ckeditor5/src/core';
import DrupalImageAlternativeTextEditing from './imagealternativetext/drupalimagealternativetextediting';
import DrupalImageAlternativeTextUi from './imagealternativetext/drupalimagealternativetextui';
/**
* The Drupal-specific image text alternative plugin.
*
* This has been implemented based on the CKEditor 5 built in image alternative
* text plugin. This plugin enhances the original upstream form with a toggle
* button that allows users to explicitly mark images as decorative, which is
* downcast to an empty `alt` attribute. This plugin also provides a warning for
* images that are missing the `alt` attribute, to ensure content authors don't
* leave the alternative text blank by accident.
*
* @see module:image/imagetextalternative~ImageTextAlternative
*
* @extends module:core/plugin~Plugin
*/
export default class DrupalImageAlternativeText extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [DrupalImageAlternativeTextEditing, DrupalImageAlternativeTextUi];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalImageAlternativeText';
}
}

View File

@@ -0,0 +1,899 @@
/* eslint-disable import/no-extraneous-dependencies */
// cspell:ignore datafilter downcasted linkimageediting emptyelement downcastdispatcher imageloadobserver
import { Plugin } from 'ckeditor5/src/core';
import { setViewAttributes } from '@ckeditor/ckeditor5-html-support/src/utils';
import ImageLoadObserver from '@ckeditor/ckeditor5-image/src/image/imageloadobserver';
/**
* @typedef {function} converterHandler
*
* Callback for a CKEditor 5 event.
*
* @param {Event} event
* The CKEditor 5 event object.
* @param {object} data
* The data associated with the event.
* @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
* The CKEditor 5 conversion API object.
*/
/**
* Provides an empty image element.
*
* @param {writer} writer
* The CKEditor 5 writer object.
*
* @return {module:engine/view/emptyelement~EmptyElement}
* The empty image element.
*
* @private
*/
function createImageViewElement(writer) {
return writer.createEmptyElement('img');
}
/**
* A simple helper method to detect number strings.
*
* @param {*} value
* The value to test.
*
* @return {boolean}
* True if the value is a string containing a number.
*
* @private
*/
function isNumberString(value) {
const parsedValue = parseFloat(value);
return !Number.isNaN(parsedValue) && value === String(parsedValue);
}
/**
* Downcasts a string that may use a %-based value.
*
* @param {string} value
* A string ending with `px` or `%`.
*
* @return {string}
* The given value if it ends with '%', otherwise the parsed integer value.
*
* @private
*/
function downcastPxOrPct(value) {
// In one specific case, override the default behavior.
if (typeof value === 'string' && value.endsWith('%')) {
return value;
}
// This matches the upstream behavior.
return `${parseInt(value, 10)}`;
}
/**
* Generates a callback that saves the entity UUID to an attribute on data
* downcast.
*
* @return {function}
* Callback that binds an event to its parameter.
*
* @private
*/
function modelEntityUuidToDataAttribute() {
/**
* Callback for the attribute:dataEntityUuid event.
*
* Saves the UUID value to the data-entity-uuid attribute.
*
* @param {Event} event
* @param {object} data
* @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
*/
function converter(event, data, conversionApi) {
const { item } = data;
const { consumable, writer } = conversionApi;
if (!consumable.consume(item, event.name)) {
return;
}
const viewElement = conversionApi.mapper.toViewElement(item);
const imageInFigure = Array.from(viewElement.getChildren()).find(
(child) => child.name === 'img',
);
writer.setAttribute(
'data-entity-uuid',
data.attributeNewValue,
imageInFigure || viewElement,
);
}
return (dispatcher) => {
dispatcher.on('attribute:dataEntityUuid', converter);
};
}
/**
* @type {Array.<{dataValue: string, modelValue: string}>}
*/
const alignmentMapping = [
{
modelValue: 'alignCenter',
dataValue: 'center',
},
{
modelValue: 'alignRight',
dataValue: 'right',
},
{
modelValue: 'alignLeft',
dataValue: 'left',
},
];
/**
* Downcasts `caption` model to `data-caption` attribute with its content
* downcasted to plain HTML.
*
* This is needed because CKEditor 5 uses the `<caption>` element internally in
* various places, which differs from Drupal which uses an attribute. For now
* to support that we have to manually repeat work done in the
* DowncastDispatcher's private methods.
*
* @param {module:core/editor/editor~Editor} editor
* The editor instance to use.
*
* @return {function}
* Callback that binds an event to its parameter.
*
* @private
*/
function viewCaptionToCaptionAttribute(editor) {
return (dispatcher) => {
dispatcher.on(
'insert:caption',
/**
* @type {converterHandler}
*/
(event, data, conversionApi) => {
const { consumable, writer, mapper } = conversionApi;
const imageUtils = editor.plugins.get('ImageUtils');
if (
!imageUtils.isImage(data.item.parent) ||
!consumable.consume(data.item, 'insert')
) {
return;
}
const range = editor.model.createRangeIn(data.item);
const viewDocumentFragment = writer.createDocumentFragment();
// Bind caption model element to the detached view document fragment so
// all content of the caption will be downcasted into that document
// fragment.
mapper.bindElements(data.item, viewDocumentFragment);
// eslint-disable-next-line no-restricted-syntax
for (const { item } of Array.from(range)) {
const itemData = {
item,
range: editor.model.createRangeOn(item),
};
// The following lines are extracted from
// DowncastDispatcher._convertInsertWithAttributes().
const eventName = `insert:${item.name || '$text'}`;
editor.data.downcastDispatcher.fire(
eventName,
itemData,
conversionApi,
);
// eslint-disable-next-line no-restricted-syntax
for (const key of item.getAttributeKeys()) {
Object.assign(itemData, {
attributeKey: key,
attributeOldValue: null,
attributeNewValue: itemData.item.getAttribute(key),
});
editor.data.downcastDispatcher.fire(
`attribute:${key}`,
itemData,
conversionApi,
);
}
}
// Unbind all the view elements that were downcasted to the document
// fragment.
// eslint-disable-next-line no-restricted-syntax
for (const child of writer
.createRangeIn(viewDocumentFragment)
.getItems()) {
mapper.unbindViewElement(child);
}
mapper.unbindViewElement(viewDocumentFragment);
// Stringify view document fragment to HTML string.
const captionText = editor.data.processor.toData(viewDocumentFragment);
if (captionText) {
const imageViewElement = mapper.toViewElement(data.item.parent);
writer.setAttribute('data-caption', captionText, imageViewElement);
}
},
// Override default caption converter.
{ priority: 'high' },
);
};
}
/**
* Generates a callback that saves the entity type value to an attribute on
* data downcast.
*
* @return {function}
* Callback that binds an event to it's parameter.
*
* @private
*/
function modelEntityTypeToDataAttribute() {
/**
* Callback for the attribute:dataEntityType event.
*
* Saves the UUID value to the data-entity-type attribute.
*
* @type {converterHandler}
*/
function converter(event, data, conversionApi) {
const { item } = data;
const { consumable, writer } = conversionApi;
if (!consumable.consume(item, event.name)) {
return;
}
const viewElement = conversionApi.mapper.toViewElement(item);
const imageInFigure = Array.from(viewElement.getChildren()).find(
(child) => child.name === 'img',
);
writer.setAttribute(
'data-entity-type',
data.attributeNewValue,
imageInFigure || viewElement,
);
}
return (dispatcher) => {
dispatcher.on('attribute:dataEntityType', converter);
};
}
/**
* Generates a callback that saves the align value to an attribute on
* data downcast.
*
* @return {function}
* Callback that binds an event to its parameter.
*
* @private
*/
function modelImageStyleToDataAttribute() {
/**
* Callback for the attribute:imageStyle event.
*
* Saves the alignment value to the data-align attribute.
*
* @type {converterHandler}
*/
function converter(event, data, conversionApi) {
const { item } = data;
const { consumable, writer } = conversionApi;
const mappedAlignment = alignmentMapping.find(
(value) => value.modelValue === data.attributeNewValue,
);
// Consume only for the values that can be converted into data-align.
if (!mappedAlignment || !consumable.consume(item, event.name)) {
return;
}
const viewElement = conversionApi.mapper.toViewElement(item);
const imageInFigure = Array.from(viewElement.getChildren()).find(
(child) => child.name === 'img',
);
writer.setAttribute(
'data-align',
mappedAlignment.dataValue,
imageInFigure || viewElement,
);
}
return (dispatcher) => {
dispatcher.on('attribute:imageStyle', converter, { priority: 'high' });
};
}
/**
* Generates a callback that handles the data downcast for the img element.
*
* @return {function}
* Callback that binds an event to its parameter.
*
* @private
*/
function viewImageToModelImage(editor) {
/**
* Callback for the element:img event.
*
* Handles the Drupal specific attributes.
*
* @type {converterHandler}
*/
function converter(event, data, conversionApi) {
const { viewItem } = data;
const { writer, consumable, safeInsert, updateConversionResult, schema } =
conversionApi;
const attributesToConsume = [];
let image;
// Not only check if a given `img` view element has been consumed, but also
// verify it has `src` attribute present.
if (!consumable.test(viewItem, { name: true, attributes: 'src' })) {
return;
}
const hasDataCaption = consumable.test(viewItem, {
name: true,
attributes: 'data-caption',
});
// Create image that's allowed in the given context. If the image has a
// caption, the image must be created as a block image to ensure the caption
// is not lost on conversion. This is based on the assumption that
// preserving the image caption is more important to the content creator
// than preserving the wrapping element that doesn't allow block images.
if (schema.checkChild(data.modelCursor, 'imageInline') && !hasDataCaption) {
image = writer.createElement('imageInline', {
src: viewItem.getAttribute('src'),
});
} else {
image = writer.createElement('imageBlock', {
src: viewItem.getAttribute('src'),
});
}
// The way that image styles are handled here is naive - it assumes that the
// image styles are configured exactly as expected by this plugin.
// @todo Add support for custom image style configurations
// https://www.drupal.org/i/3270693.
if (
editor.plugins.has('ImageStyleEditing') &&
consumable.test(viewItem, { name: true, attributes: 'data-align' })
) {
const dataAlign = viewItem.getAttribute('data-align');
const mappedAlignment = alignmentMapping.find(
(value) => value.dataValue === dataAlign,
);
if (mappedAlignment) {
writer.setAttribute('imageStyle', mappedAlignment.modelValue, image);
// Make sure the attribute can be consumed after successful `safeInsert`
// operation.
attributesToConsume.push('data-align');
}
}
// Check if the view element has still unconsumed `data-caption` attribute.
if (hasDataCaption) {
// Create `caption` model element. Thanks to that element the rest of the
// `ckeditor5-plugin` converters can recognize this image as a block image
// with a caption.
const caption = writer.createElement('caption');
// Parse HTML from data-caption attribute and upcast it to model fragment.
const viewFragment = editor.data.processor.toView(
viewItem.getAttribute('data-caption'),
);
// Consumable must know about those newly parsed view elements.
conversionApi.consumable.constructor.createFrom(
viewFragment,
conversionApi.consumable,
);
conversionApi.convertChildren(viewFragment, caption);
// Insert the caption element into image, as a last child.
writer.append(caption, image);
// Make sure the attribute can be consumed after successful `safeInsert`
// operation.
attributesToConsume.push('data-caption');
}
if (
consumable.test(viewItem, { name: true, attributes: 'data-entity-uuid' })
) {
writer.setAttribute(
'dataEntityUuid',
viewItem.getAttribute('data-entity-uuid'),
image,
);
attributesToConsume.push('data-entity-uuid');
}
if (
consumable.test(viewItem, { name: true, attributes: 'data-entity-type' })
) {
writer.setAttribute(
'dataEntityType',
viewItem.getAttribute('data-entity-type'),
image,
);
attributesToConsume.push('data-entity-type');
}
// Try to place the image in the allowed position.
if (!safeInsert(image, data.modelCursor)) {
return;
}
// Mark given element as consumed. Now other converters will not process it
// anymore.
consumable.consume(viewItem, {
name: true,
attributes: attributesToConsume,
});
// Make sure `modelRange` and `modelCursor` is up to date after inserting
// new nodes into the model.
updateConversionResult(image, data);
}
return (dispatcher) => {
dispatcher.on('element:img', converter, { priority: 'high' });
};
}
/**
* General HTML Support integration for attributes on links wrapping images.
*
* This plugin needs to integrate with GHS manually because upstream image link
* plugin GHS integration assumes that the `<a>` element is inside the
* `<imageBlock>` which is not true in the case of Drupal.
*
* @param {module:html-support/datafilter~DataFilter} dataFilter
* The General HTML support data filter.
*
* @return {function}
* Callback that binds an event to its parameter.
*/
function upcastImageBlockLinkGhsAttributes(dataFilter) {
/**
* Callback for the element:img upcast event.
*
* @type {converterHandler}
*/
function converter(event, data, conversionApi) {
if (!data.modelRange) {
return;
}
const viewImageElement = data.viewItem;
const viewContainerElement = viewImageElement.parent;
if (!viewContainerElement.is('element', 'a')) {
return;
}
if (!data.modelRange.getContainedElement().is('element', 'imageBlock')) {
return;
}
const viewAttributes = dataFilter.processViewAttributes(
viewContainerElement,
conversionApi,
);
if (viewAttributes) {
conversionApi.writer.setAttribute(
'htmlLinkAttributes',
viewAttributes,
data.modelRange,
);
}
}
return (dispatcher) => {
dispatcher.on('element:img', converter, {
priority: 'high',
});
};
}
/**
* Modified alternative implementation of linkimageediting.js' downcastImageLink.
*
* @return {function}
* Callback that binds an event to its parameter.
*
* @private
*/
function downcastBlockImageLink() {
/**
* Callback for the attribute:linkHref event.
*
* @type {converterHandler}
*/
function converter(event, data, conversionApi) {
if (!conversionApi.consumable.consume(data.item, event.name)) {
return;
}
// The image will be already converted - so it will be present in the view.
const image = conversionApi.mapper.toViewElement(data.item);
const writer = conversionApi.writer;
// 1. Create an empty link element.
const linkElement = writer.createContainerElement('a', {
href: data.attributeNewValue,
});
// 2. Insert link before the associated image.
writer.insert(writer.createPositionBefore(image), linkElement);
// 3. Move the image into the link.
writer.move(
writer.createRangeOn(image),
writer.createPositionAt(linkElement, 0),
);
// Modified alternative implementation of GHS' addBlockImageLinkAttributeConversion().
// This is happening here as well to avoid a race condition with the link
// element not yet existing.
if (
conversionApi.consumable.consume(
data.item,
'attribute:htmlLinkAttributes:imageBlock',
)
) {
setViewAttributes(
conversionApi.writer,
data.item.getAttribute('htmlLinkAttributes'),
linkElement,
);
}
}
return (dispatcher) => {
dispatcher.on('attribute:linkHref:imageBlock', converter, {
priority: 'high',
});
};
}
/**
* Drupal Image plugin.
*
* This plugin extends the CKEditor 5 image plugin with custom attributes, and
* removes a wrapping `<figure>` from `<img>` elements in the data downcast.
*
* @private
*/
export default class DrupalImageEditing extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return ['ImageUtils'];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalImageEditing';
}
/**
* @inheritdoc
*/
init() {
const { editor } = this;
const { conversion } = editor;
const { schema } = editor.model;
if (schema.isRegistered('imageInline')) {
schema.extend('imageInline', {
allowAttributes: ['dataEntityUuid', 'dataEntityType', 'isDecorative'],
});
}
if (schema.isRegistered('imageBlock')) {
schema.extend('imageBlock', {
allowAttributes: ['dataEntityUuid', 'dataEntityType', 'isDecorative'],
});
}
// Conversion.
conversion
.for('upcast')
.add(viewImageToModelImage(editor))
// The width attribute to resizedWidth conversion.
.attributeToAttribute({
view: {
name: 'img',
key: 'width',
},
model: {
key: 'resizedWidth',
value: (viewElement) => {
// Support resizing using pixels and (the HTML 4.01-only) percentages.
if (isNumberString(viewElement.getAttribute('width'))) {
return `${parseInt(viewElement.getAttribute('width'), 10)}px`;
}
return viewElement.getAttribute('width').trim();
},
},
})
// The height attribute to resizedHeight conversion.
.attributeToAttribute({
view: {
name: 'img',
key: 'height',
},
model: {
key: 'resizedHeight',
value: (viewElement) => {
// Support resizing using pixels and (the HTML 4.01-only) percentages.
if (isNumberString(viewElement.getAttribute('height'))) {
return `${parseInt(viewElement.getAttribute('height'), 10)}px`;
}
return viewElement.getAttribute('height').trim();
},
},
});
if (editor.plugins.has('DataFilter')) {
const dataFilter = editor.plugins.get('DataFilter');
conversion
.for('upcast')
.add(upcastImageBlockLinkGhsAttributes(dataFilter));
}
conversion
.for('downcast')
.add(modelEntityUuidToDataAttribute())
.add(modelEntityTypeToDataAttribute());
conversion
.for('dataDowncast')
.add(viewCaptionToCaptionAttribute(editor))
.elementToElement({
model: 'imageBlock',
view: (modelElement, { writer }) =>
createImageViewElement(writer, 'imageBlock'),
converterPriority: 'high',
})
.elementToElement({
model: 'imageInline',
view: (modelElement, { writer }) =>
createImageViewElement(writer, 'imageInline'),
converterPriority: 'high',
})
.add(modelImageStyleToDataAttribute())
.add(downcastBlockImageLink())
// ⚠️ Everything below this point is copy/pasted directly from https://github.com/ckeditor/ckeditor5/pull/15222,
// to continue to use the `width` and `height` attributes to indicate resized width and height. This is necessary
// since CKEditor 5 v40.0.0.
// @see https://github.com/ckeditor/ckeditor5/releases/tag/v40.0.0
// Exceptions are:
// - reformatting to comply with Drupal's eslint-enforced coding standards
// - support for %-based image resizes
// There is a resizedWidth so use it as a width attribute in data.
.attributeToAttribute({
model: {
name: 'imageBlock',
key: 'resizedWidth',
},
view: (attributeValue) => ({
key: 'width',
value: downcastPxOrPct(attributeValue),
}),
converterPriority: 'high',
})
.attributeToAttribute({
model: {
name: 'imageInline',
key: 'resizedWidth',
},
view: (attributeValue) => ({
key: 'width',
value: downcastPxOrPct(attributeValue),
}),
converterPriority: 'high',
})
// There is a resizedHeight so use it as a height attribute in data.
.attributeToAttribute({
model: {
name: 'imageBlock',
key: 'resizedHeight',
},
view: (attributeValue) => ({
key: 'height',
value: downcastPxOrPct(attributeValue),
}),
converterPriority: 'high',
})
.attributeToAttribute({
model: {
name: 'imageInline',
key: 'resizedHeight',
},
view: (attributeValue) => ({
key: 'height',
value: downcastPxOrPct(attributeValue),
}),
converterPriority: 'high',
})
// Natural width should be used only if resizedWidth is not specified (is equal to natural width).
.attributeToAttribute({
model: {
name: 'imageBlock',
key: 'width',
},
view: (attributeValue, { consumable }, data) => {
if (data.item.hasAttribute('resizedWidth')) {
// Natural width consumed and not down-casted (because resizedWidth was used to downcast to the width attribute).
consumable.consume(data.item, 'attribute:width');
return null;
}
// There is no resizedWidth so downcast natural width to the attribute in data.
return {
key: 'width',
value: attributeValue,
};
},
converterPriority: 'high',
})
.attributeToAttribute({
model: {
name: 'imageInline',
key: 'width',
},
view: (attributeValue, { consumable }, data) => {
if (data.item.hasAttribute('resizedWidth')) {
// Natural width consumed and not down-casted (because resizedWidth was used to downcast to the width attribute).
consumable.consume(data.item, 'attribute:width');
return null;
}
// There is no resizedWidth so downcast natural width to the attribute in data.
return {
key: 'width',
value: attributeValue,
};
},
converterPriority: 'high',
})
// Natural height converted to resized height attribute (based on aspect ratio and resized width if available).
.attributeToAttribute({
model: {
name: 'imageBlock',
key: 'height',
},
view: (attributeValue, conversionApi, data) => {
if (data.item.hasAttribute('resizedWidth')) {
// TRICKY: Drupal must continue to support %-based image resizes.
// @see https://www.drupal.org/project/drupal/issues/3249592
// @see https://www.drupal.org/project/drupal/issues/3348603
if (data.item.getAttribute('resizedWidth').endsWith('%')) {
return {
key: 'height',
value: data.item.getAttribute('resizedWidth'),
};
}
// The resizedWidth is present so calculate height from aspect ratio.
const resizedWidth = parseInt(
data.item.getAttribute('resizedWidth'),
10,
);
const naturalWidth = parseInt(data.item.getAttribute('width'), 10);
const naturalHeight = parseInt(attributeValue, 10);
const aspectRatio = naturalWidth / naturalHeight;
return {
key: 'height',
value: `${Math.round(resizedWidth / aspectRatio)}`,
};
}
// There is no resizedWidth so using natural height attribute.
return {
key: 'height',
value: attributeValue,
};
},
converterPriority: 'high',
})
.attributeToAttribute({
model: {
name: 'imageInline',
key: 'height',
},
view: (attributeValue, conversionApi, data) => {
if (data.item.hasAttribute('resizedWidth')) {
// TRICKY: Drupal must continue to support %-based image resizes.
// @see https://www.drupal.org/project/drupal/issues/3249592
// @see https://www.drupal.org/project/drupal/issues/3348603
if (data.item.getAttribute('resizedWidth').endsWith('%')) {
return {
key: 'height',
value: data.item.getAttribute('resizedWidth'),
};
}
// The resizedWidth is present so calculate height from aspect ratio.
const resizedWidth = parseInt(
data.item.getAttribute('resizedWidth'),
10,
);
const naturalWidth = parseInt(data.item.getAttribute('width'), 10);
const naturalHeight = parseInt(attributeValue, 10);
const aspectRatio = naturalWidth / naturalHeight;
return {
key: 'height',
value: `${Math.round(resizedWidth / aspectRatio)}`,
};
}
// There is no resizedWidth so using natural height attribute.
return {
key: 'height',
value: attributeValue,
};
},
converterPriority: 'high',
});
// Waiting for any new images loaded, so we can set their natural width and height.
// @see https://github.com/ckeditor/ckeditor5/pull/15222
editor.editing.view.addObserver(ImageLoadObserver);
const imageUtils = editor.plugins.get('ImageUtils');
editor.editing.view.document.on('imageLoaded', (evt, domEvent) => {
const imgViewElement = editor.editing.view.domConverter.mapDomToView(
domEvent.target,
);
if (!imgViewElement) {
return;
}
const viewElement =
imageUtils.getImageWidgetFromImageView(imgViewElement);
if (!viewElement) {
return;
}
const modelElement = editor.editing.mapper.toModelElement(viewElement);
if (!modelElement) {
return;
}
editor.model.enqueueChange({ isUndoable: false }, () => {
imageUtils.setImageNaturalSizeAttributes(modelElement);
});
});
}
}

View File

@@ -0,0 +1,168 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore imagealternativetext drupalimagealternativetextediting drupalimagetextalternativecommand textalternativemissingview imagetextalternativecommand */
/**
* @module drupalImage/imagealternativetext/drupalimagealternativetextediting
*/
import { Plugin } from 'ckeditor5/src/core';
import ImageTextAlternativeCommand from '@ckeditor/ckeditor5-image/src/imagetextalternative/imagetextalternativecommand';
/**
* The Drupal image alternative text editing plugin.
*
* Registers the `imageTextAlternative` command.
*
* @extends module:core/plugin~Plugin
*
* @internal
*/
export default class DrupalImageTextAlternativeEditing extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return ['ImageUtils'];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalImageAlternativeTextEditing';
}
constructor(editor) {
super(editor);
/**
* Keeps references to instances of `TextAlternativeMissingView`.
*
* @member {Set<module:drupalImage/imagetextalternative/ui/textalternativemissingview~TextAlternativeMissingView>} #_missingAltTextViewReferences
* @private
*/
this._missingAltTextViewReferences = new Set();
}
/**
* @inheritdoc
*/
init() {
const editor = this.editor;
editor.conversion
.for('editingDowncast')
.add(this._imageEditingDowncastConverter('attribute:alt', editor))
// Including changes to src ensures the converter will execute for images
// that do not yet have alt attributes, as we specifically want to add the
// missing alt text warning to images without alt attributes.
.add(this._imageEditingDowncastConverter('attribute:src', editor));
editor.commands.add(
'imageTextAlternative',
new ImageTextAlternativeCommand(this.editor),
);
editor.editing.view.on('render', () => {
// eslint-disable-next-line no-restricted-syntax
for (const view of this._missingAltTextViewReferences) {
// Destroy view instances that are not connected to the DOM to ensure
// there are no memory leaks.
// https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected
if (!view.button.element.isConnected) {
view.destroy();
this._missingAltTextViewReferences.delete(view);
}
}
});
}
/**
* Helper that generates model to editing view converters to display missing
* alt text warning.
*
* @param {string} eventName
* The name of the event the converter should be attached to.
*
* @return {function}
* A function that attaches downcast converter to the conversion dispatcher.
*
* @private
*/
_imageEditingDowncastConverter(eventName) {
const converter = (evt, data, conversionApi) => {
const editor = this.editor;
const imageUtils = editor.plugins.get('ImageUtils');
if (!imageUtils.isImage(data.item)) {
return;
}
const viewElement = conversionApi.mapper.toViewElement(data.item);
const existingWarning = Array.from(viewElement.getChildren()).find(
(child) => child.getCustomProperty('drupalImageMissingAltWarning'),
);
const hasAlt = data.item.hasAttribute('alt');
if (hasAlt) {
// Remove existing warning if alt text is set and there's an existing
// warning.
if (existingWarning) {
conversionApi.writer.remove(existingWarning);
}
return;
}
// Nothing to do if alt text doesn't exist and there's already an existing
// warning.
if (existingWarning) {
return;
}
const view = editor.ui.componentFactory.create(
'drupalImageAlternativeTextMissing',
);
view.listenTo(editor.ui, 'update', () => {
const selectionRange = editor.model.document.selection.getFirstRange();
const imageRange = editor.model.createRangeOn(data.item);
// Set the view `isSelected` property depending on whether the model
// element associated to the view element is in the selection.
view.set({
isSelected:
selectionRange.containsRange(imageRange) ||
selectionRange.isIntersecting(imageRange),
});
});
view.render();
// Add reference to the created view element so that it can be destroyed
// when the view is no longer connected.
this._missingAltTextViewReferences.add(view);
const html = conversionApi.writer.createUIElement(
'span',
{
class: 'image-alternative-text-missing-wrapper',
},
function (domDocument) {
const wrapperDomElement = this.toDomElement(domDocument);
wrapperDomElement.appendChild(view.element);
return wrapperDomElement;
},
);
conversionApi.writer.setCustomProperty(
'drupalImageMissingAltWarning',
true,
html,
);
conversionApi.writer.insert(
conversionApi.writer.createPositionAt(viewElement, 'end'),
html,
);
};
return (dispatcher) => {
dispatcher.on(eventName, converter, { priority: 'low' });
};
}
}

View File

@@ -0,0 +1,327 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalimagealternativetextui contextualballoon componentfactory imagealternativetextformview missingalternativetextview imagetextalternativeui imagealternativetext */
/**
* @module drupalImage/imagealternativetext/drupalimagealternativetextui
*/
import { Plugin, icons } from 'ckeditor5/src/core';
import {
ButtonView,
ContextualBalloon,
clickOutsideHandler,
} from 'ckeditor5/src/ui';
import {
repositionContextualBalloon,
getBalloonPositionData,
} from '@ckeditor/ckeditor5-image/src/image/ui/utils';
import ImageAlternativeTextFormView from './ui/imagealternativetextformview';
import MissingAlternativeTextView from './ui/missingalternativetextview';
/**
* The Drupal-specific image alternative text UI plugin.
*
* This plugin is based on a version of the upstream alternative text UI plugin.
* This override enhances the UI with a new form element which allows marking
* images explicitly as decorative. This plugin also provides a UI component
* that can be displayed on images that are missing alternative text.
*
* The logic related to visibility, positioning, and keystrokes are unchanged
* from the upstream implementation.
*
* The plugin uses the contextual balloon.
*
* @see module:image/imagetextalternative/imagetextalternativeui~ImageTextAlternativeUI
* @see module:ui/panel/balloon/contextualballoon~ContextualBalloon
*
* @extends module:core/plugin~Plugin
*
* @internal
*/
export default class DrupalImageAlternativeTextUi extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [ContextualBalloon];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalImageTextAlternativeUI';
}
/**
* @inheritdoc
*/
init() {
this._createButton();
this._createForm();
this._createMissingAltTextComponent();
const showAlternativeTextForm = () => {
const imageUtils = this.editor.plugins.get('ImageUtils');
// Show form after upload if there's an image widget in the current
// selection.
if (
imageUtils.getClosestSelectedImageWidget(
this.editor.editing.view.document.selection,
)
) {
this._showForm();
}
};
if (this.editor.commands.get('insertImage')) {
const insertImage = this.editor.commands.get('insertImage');
insertImage.on('execute', showAlternativeTextForm);
}
if (this.editor.plugins.has('ImageUploadEditing')) {
const imageUploadEditing = this.editor.plugins.get('ImageUploadEditing');
imageUploadEditing.on('uploadComplete', showAlternativeTextForm);
}
}
/**
* Creates a missing alt text view which can be displayed within image widgets
* where the image is missing alt text.
*
* The component is registered in the editor component factory.
*
* @see module:ui/componentfactory~ComponentFactory
*
* @private
*/
_createMissingAltTextComponent() {
this.editor.ui.componentFactory.add(
'drupalImageAlternativeTextMissing',
(locale) => {
const view = new MissingAlternativeTextView(locale);
view.listenTo(view.button, 'execute', () => {
// If the form is already in the balloon, it needs to be removed to
// avoid having multiple instances of the form in the balloon. This
// happens only in the edge case where this event is executed while
// the form is still in the balloon.
if (this._isInBalloon) {
this._balloon.remove(this._form);
}
this._showForm();
});
view.listenTo(this.editor.ui, 'update', () => {
view.set({ isVisible: !this._isVisible || !view.isSelected });
});
return view;
},
);
}
/**
* @inheritdoc
*/
destroy() {
super.destroy();
// Destroy created UI components as they are not automatically destroyed
// @see https://github.com/ckeditor/ckeditor5/issues/1341
this._form.destroy();
}
/**
* Creates a button showing the balloon panel for changing the image text
* alternative and registers it in the editor component factory.
*
* @see module:ui/componentfactory~ComponentFactory
*
* @private
*/
_createButton() {
const editor = this.editor;
editor.ui.componentFactory.add('drupalImageAlternativeText', (locale) => {
const command = editor.commands.get('imageTextAlternative');
const view = new ButtonView(locale);
view.set({
label: Drupal.t('Change image alternative text'),
icon: icons.lowVision,
tooltip: true,
});
view.bind('isEnabled').to(command, 'isEnabled');
this.listenTo(view, 'execute', () => {
this._showForm();
});
return view;
});
}
/**
* Creates the text alternative form view.
*
* @private
*/
_createForm() {
const editor = this.editor;
const view = editor.editing.view;
const viewDocument = view.document;
const imageUtils = editor.plugins.get('ImageUtils');
/**
* The contextual balloon plugin instance.
*
* @private
* @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
*/
this._balloon = this.editor.plugins.get('ContextualBalloon');
/**
* A form used for changing the `alt` text value.
*
* @member {module:drupalImage/imagetextalternative/ui/imagealternativetextformview~ImageAlternativeTextFormView}
*/
this._form = new ImageAlternativeTextFormView(editor.locale);
// Render the form so its #element is available for clickOutsideHandler.
this._form.render();
this.listenTo(this._form, 'submit', () => {
editor.execute('imageTextAlternative', {
newValue: this._form.decorativeToggle.isOn
? ''
: this._form.labeledInput.fieldView.element.value,
});
this._hideForm(true);
});
this.listenTo(this._form, 'cancel', () => {
this._hideForm(true);
});
// Reposition the toolbar when the decorative toggle is executed because
// it has an impact on the form size.
this.listenTo(this._form.decorativeToggle, 'execute', () => {
repositionContextualBalloon(editor);
});
// Close the form on Esc key press.
this._form.keystrokes.set('Esc', (data, cancel) => {
this._hideForm(true);
cancel();
});
// Reposition the balloon or hide the form if an image widget is no longer
// selected.
this.listenTo(editor.ui, 'update', () => {
if (!imageUtils.getClosestSelectedImageWidget(viewDocument.selection)) {
this._hideForm(true);
} else if (this._isVisible) {
repositionContextualBalloon(editor);
}
});
// Close on click outside of balloon panel element.
clickOutsideHandler({
emitter: this._form,
activator: () => this._isVisible,
contextElements: [this._balloon.view.element],
callback: () => this._hideForm(),
});
}
/**
* Shows the form in the balloon.
*
* @private
*/
_showForm() {
if (this._isVisible) {
return;
}
const editor = this.editor;
const command = editor.commands.get('imageTextAlternative');
const decorativeToggle = this._form.decorativeToggle;
const labeledInput = this._form.labeledInput;
this._form.disableCssTransitions();
if (!this._isInBalloon) {
this._balloon.add({
view: this._form,
position: getBalloonPositionData(editor),
});
}
decorativeToggle.isOn = command.value === '';
// Make sure that each time the panel shows up, the field remains in sync
// with the value of the command. If the user typed in the input, then
// canceled the balloon (`labeledInput#value` stays unaltered) and re-opened
// it without changing the value of the command, they would see the old
// value instead of the actual value of the command.
// https://github.com/ckeditor/ckeditor5-image/issues/114
labeledInput.fieldView.element.value = command.value || '';
labeledInput.fieldView.value = labeledInput.fieldView.element.value;
if (!decorativeToggle.isOn) {
labeledInput.fieldView.select();
} else {
decorativeToggle.focus();
}
this._form.enableCssTransitions();
}
/**
* Removes the form from the balloon.
*
* @param {Boolean} [focusEditable=false]
* Controls whether the editing view is focused afterwards.
*
* @private
*/
_hideForm(focusEditable) {
if (!this._isInBalloon) {
return;
}
// Blur the input element before removing it from DOM to prevent issues in
// some browsers.
// See https://github.com/ckeditor/ckeditor5/issues/1501.
if (this._form.focusTracker.isFocused) {
this._form.saveButtonView.focus();
}
this._balloon.remove(this._form);
if (focusEditable) {
this.editor.editing.view.focus();
}
}
/**
* Returns `true` when the form is the visible view in the balloon.
*
* @type {Boolean}
*
* @private
*/
get _isVisible() {
return this._balloon.visibleView === this._form;
}
/**
* Returns `true` when the form is in the balloon.
*
* @type {Boolean}
*
* @private
*/
get _isInBalloon() {
return this._balloon.hasView(this._form);
}
}

View File

@@ -0,0 +1,279 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore focustracker keystrokehandler labeledfield labeledfieldview buttonview viewcollection focusables focuscycler switchbuttonview imagealternativetextformview imagealternativetext */
/**
* @module drupalImage/imagealternativetext/ui/imagealternativetextformview
*/
import {
ButtonView,
FocusCycler,
LabeledFieldView,
SwitchButtonView,
View,
ViewCollection,
createLabeledInputText,
injectCssTransitionDisabler,
submitHandler,
} from 'ckeditor5/src/ui';
import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils';
import { icons } from 'ckeditor5/src/core';
/**
* A class rendering alternative text form view.
*
* @extends module:ui/view~View
*
* @internal
*/
export default class ImageAlternativeTextFormView extends View {
/**
* @inheritdoc
*/
constructor(locale) {
super(locale);
/**
* Tracks information about the DOM focus in the form.
*
* @readonly
* @member {module:utils/focustracker~FocusTracker}
*/
this.focusTracker = new FocusTracker();
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*
* @readonly
* @member {module:utils/keystrokehandler~KeystrokeHandler}
*/
this.keystrokes = new KeystrokeHandler();
/**
* A toggle for marking the image as decorative.
*
* @member {module:ui/button/switchbuttonview~SwitchButtonView} #decorativeToggle
*/
this.decorativeToggle = this._decorativeToggleView();
/**
* An input with a label.
*
* @member {module:ui/labeledfield/labeledfieldview~LabeledFieldView} #labeledInput
*/
this.labeledInput = this._createLabeledInputView();
/**
* A button used to submit the form.
*
* @member {module:ui/button/buttonview~ButtonView} #saveButtonView
*/
this.saveButtonView = this._createButton(
Drupal.t('Save'),
icons.check,
'ck-button-save',
);
this.saveButtonView.type = 'submit';
// Save button is disabled when image is not decorative and alt text is
// empty.
this.saveButtonView
.bind('isEnabled')
.to(
this.decorativeToggle,
'isOn',
this.labeledInput,
'isEmpty',
(isDecorativeToggleOn, isLabeledInputEmpty) =>
isDecorativeToggleOn || !isLabeledInputEmpty,
);
/**
* A button used to cancel the form.
*
* @member {module:ui/button/buttonview~ButtonView} #cancelButtonView
*/
this.cancelButtonView = this._createButton(
Drupal.t('Cancel'),
icons.cancel,
'ck-button-cancel',
'cancel',
);
/**
* A collection of views which can be focused in the form.
*
* @member {module:ui/viewcollection~ViewCollection}
*
* @readonly
* @protected
*/
this._focusables = new ViewCollection();
/**
* Helps cycling over focusables in the form.
*
* @member {module:ui/focuscycler~FocusCycler}
*
* @readonly
* @protected
*/
this._focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
// Navigate form fields backwards using the Shift + Tab keystroke.
focusPrevious: 'shift + tab',
// Navigate form fields forwards using the Tab key.
focusNext: 'tab',
},
});
this.setTemplate({
tag: 'form',
attributes: {
class: [
'ck',
'ck-text-alternative-form',
'ck-text-alternative-form--with-decorative-toggle',
'ck-responsive-form',
],
// https://github.com/ckeditor/ckeditor5-image/issues/40
tabindex: '-1',
},
children: [
{
tag: 'div',
attributes: {
class: ['ck', 'ck-text-alternative-form__decorative-toggle'],
},
children: [this.decorativeToggle],
},
this.labeledInput,
this.saveButtonView,
this.cancelButtonView,
],
});
injectCssTransitionDisabler(this);
}
/**
* @inheritdoc
*/
render() {
super.render();
this.keystrokes.listenTo(this.element);
submitHandler({ view: this });
[
this.decorativeToggle,
this.labeledInput,
this.saveButtonView,
this.cancelButtonView,
].forEach((v) => {
// Register the view as focusable.
this._focusables.add(v);
// Register the view in the focus tracker.
this.focusTracker.add(v.element);
});
}
/**
* @inheritdoc
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Creates the button view.
*
* @param {String} label
* The button label
* @param {String} icon
* The button's icon.
* @param {String} className
* The additional button CSS class name.
* @param {String} [eventName]
* The event name that the ButtonView#execute event will be delegated to.
* @returns {module:ui/button/buttonview~ButtonView}
* The button view instance.
*
* @private
*/
_createButton(label, icon, className, eventName) {
const button = new ButtonView(this.locale);
button.set({
label,
icon,
tooltip: true,
});
button.extendTemplate({
attributes: {
class: className,
},
});
if (eventName) {
button.delegate('execute').to(this, eventName);
}
return button;
}
/**
* Creates an input with a label.
*
* @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView}
* Labeled field view instance.
*
* @private
*/
_createLabeledInputView() {
const labeledInput = new LabeledFieldView(
this.locale,
createLabeledInputText,
);
labeledInput
.bind('class')
.to(this.decorativeToggle, 'isOn', (value) => (value ? 'ck-hidden' : ''));
labeledInput.label = Drupal.t('Alternative text');
return labeledInput;
}
/**
* Creates a decorative image toggle view.
*
* @return {module:ui/button/switchbuttonview~SwitchButtonView}
* Decorative image toggle view instance.
*
* @private
*/
_decorativeToggleView() {
const decorativeToggle = new SwitchButtonView(this.locale);
decorativeToggle.set({
withText: true,
label: Drupal.t('Decorative image'),
});
decorativeToggle.on('execute', () => {
decorativeToggle.set('isOn', !decorativeToggle.isOn);
});
return decorativeToggle;
}
}

View File

@@ -0,0 +1,48 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore imagetextalternative missingalternativetextview imagealternativetext */
import { View, ButtonView } from 'ckeditor5/src/ui';
/**
* @module drupalImage/imagealternativetext/ui/missingalternativetextview
*/
/**
* A class rendering missing alt text view.
*
* @extends module:ui/view~View
*
* @internal
*/
export default class MissingAlternativeTextView extends View {
/**
* @inheritdoc
*/
constructor(locale) {
super(locale);
const bind = this.bindTemplate;
this.set('isVisible');
this.set('isSelected');
const label = Drupal.t('Add missing alternative text');
this.button = new ButtonView(locale);
this.button.set({
label,
tooltip: false,
withText: true,
});
this.setTemplate({
tag: 'span',
attributes: {
class: [
'image-alternative-text-missing',
bind.to('isVisible', (value) => (value ? '' : 'ck-hidden')),
],
title: label,
},
children: [this.button],
});
}
}

View File

@@ -0,0 +1,49 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore uploadurl drupalimageuploadadapter */
import { Plugin } from 'ckeditor5/src/core';
import { FileRepository } from 'ckeditor5/src/upload';
import { logWarning } from 'ckeditor5/src/utils';
import DrupalImageUploadAdapter from './drupalimageuploadadapter';
/**
* Provides a Drupal upload adapter.
*
* @private
*/
export default class DrupalFileRepository extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [FileRepository];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalFileRepository';
}
/**
* @inheritdoc
*/
init() {
const options = this.editor.config.get('drupalImageUpload');
if (!options) {
return;
}
if (!options.uploadUrl) {
logWarning('simple-upload-adapter-missing-uploadurl');
return;
}
this.editor.plugins.get(FileRepository).createUploadAdapter = (loader) => {
return new DrupalImageUploadAdapter(loader, options);
};
}
}

View File

@@ -0,0 +1,29 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalimageuploadediting drupalfilerepository */
import { Plugin } from 'ckeditor5/src/core';
import DrupalImageUploadEditing from './drupalimageuploadediting';
import DrupalFileRepository from './drupalfilerepository';
/**
* Integrates the CKEditor image upload with Drupal.
*
* @private
*/
class DrupalImageUpload extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [DrupalFileRepository, DrupalImageUploadEditing];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalImageUpload';
}
}
export default DrupalImageUpload;

View File

@@ -0,0 +1,154 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore simpleuploadadapter filerepository */
/**
* Upload adapter.
*
* Copied from @ckeditor5/ckeditor5-upload/src/adapters/simpleuploadadapter
*
* @private
* @implements module:upload/filerepository~UploadAdapter
*/
export default class DrupalImageUploadAdapter {
/**
* Creates a new adapter instance.
*
* @param {module:upload/filerepository~FileLoader} loader
* The file loader.
* @param {module:upload/adapters/simpleuploadadapter~SimpleUploadConfig} options
* The upload options.
*/
constructor(loader, options) {
/**
* FileLoader instance to use during the upload.
*
* @member {module:upload/filerepository~FileLoader} #loader
*/
this.loader = loader;
/**
* The configuration of the adapter.
*
* @member {module:upload/adapters/simpleuploadadapter~SimpleUploadConfig} #options
*/
this.options = options;
}
/**
* Starts the upload process.
*
* @see module:upload/filerepository~UploadAdapter#upload
* @return {Promise}
* Promise that the upload will be processed.
*/
upload() {
return this.loader.file.then(
(file) =>
new Promise((resolve, reject) => {
this._initRequest();
this._initListeners(resolve, reject, file);
this._sendRequest(file);
}),
);
}
/**
* Aborts the upload process.
*
* @see module:upload/filerepository~UploadAdapter#abort
*/
abort() {
if (this.xhr) {
this.xhr.abort();
}
}
/**
* Initializes the `XMLHttpRequest` object using the URL specified as
*
* {@link module:upload/adapters/simpleuploadadapter~SimpleUploadConfig#uploadUrl `simpleUpload.uploadUrl`} in the editor's
* configuration.
*/
_initRequest() {
this.xhr = new XMLHttpRequest();
this.xhr.open('POST', this.options.uploadUrl, true);
this.xhr.responseType = 'json';
}
/**
* Initializes XMLHttpRequest listeners
*
* @private
*
* @param {Function} resolve
* Callback function to be called when the request is successful.
* @param {Function} reject
* Callback function to be called when the request cannot be completed.
* @param {File} file
* Native File object.
*/
_initListeners(resolve, reject, file) {
const xhr = this.xhr;
const loader = this.loader;
const genericErrorText = `Couldn't upload file: ${file.name}.`;
xhr.addEventListener('error', () => reject(genericErrorText));
xhr.addEventListener('abort', () => reject());
xhr.addEventListener('load', () => {
const response = xhr.response;
if (!response || response.error) {
return reject(
response && response.error && response.error.message
? response.error.message
: genericErrorText,
);
}
// Resolve with the `urls` property and pass the response
// to allow customizing the behavior of features relying on the upload adapters.
resolve({
response,
urls: { default: response.url },
});
});
// Upload progress when it is supported.
if (xhr.upload) {
xhr.upload.addEventListener('progress', (evt) => {
if (evt.lengthComputable) {
loader.uploadTotal = evt.total;
loader.uploaded = evt.loaded;
}
});
}
}
/**
* Prepares the data and sends the request.
*
* @param {File} file
* File instance to be uploaded.
*/
_sendRequest(file) {
// Set headers if specified.
const headers = this.options.headers || {};
// Use the withCredentials flag if specified.
const withCredentials = this.options.withCredentials || false;
Object.keys(headers).forEach((headerName) => {
this.xhr.setRequestHeader(headerName, headers[headerName]);
});
this.xhr.withCredentials = withCredentials;
// Prepare the form data.
const data = new FormData();
data.append('upload', file);
// Send the request.
this.xhr.send(data);
}
}

View File

@@ -0,0 +1,34 @@
/* eslint-disable import/no-extraneous-dependencies */
import { Plugin } from 'ckeditor5/src/core';
/**
* Adds Drupal-specific attributes to the CKEditor 5 image element.
*
* @private
*/
export default class DrupalImageUploadEditing extends Plugin {
/**
* @inheritdoc
*/
init() {
const { editor } = this;
const imageUploadEditing = editor.plugins.get('ImageUploadEditing');
imageUploadEditing.on('uploadComplete', (evt, { data, imageElement }) => {
editor.model.change((writer) => {
writer.setAttribute('dataEntityUuid', data.response.uuid, imageElement);
writer.setAttribute(
'dataEntityType',
data.response.entity_type,
imageElement,
);
});
});
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalImageUploadEditing';
}
}

View File

@@ -0,0 +1,14 @@
// cspell:ignore imageupload insertimage drupalimage drupalimageupload drupalinsertimage
import DrupalImage from './drupalimage';
import DrupalImageUpload from './imageupload/drupalimageupload';
import DrupalInsertImage from './insertimage/drupalinsertimage';
/**
* @private
*/
export default {
DrupalImage,
DrupalImageUpload,
DrupalInsertImage,
};

View File

@@ -0,0 +1,30 @@
/* eslint-disable import/no-extraneous-dependencies */
import { Plugin } from 'ckeditor5/src/core';
/**
* Provides a toolbar item for inserting images.
*
* @private
*/
class DrupalInsertImage extends Plugin {
/**
* @inheritdoc
*/
init() {
const { editor } = this;
// This component is a shell around CKEditor 5 upstream insertImage button
// to retain backwards compatibility.
editor.ui.componentFactory.add('drupalInsertImage', () => {
return editor.ui.componentFactory.create('insertImage');
});
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalInsertImage';
}
}
export default DrupalInsertImage;

View File

@@ -0,0 +1,49 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalelementstyle drupalelementstyleui drupalelementstyleediting imagestyle drupalmediatoolbar drupalmediaediting */
import { Plugin } from 'ckeditor5/src/core';
import DrupalElementStyleUi from './drupalelementstyle/drupalelementstyleui';
import DrupalElementStyleEditing from './drupalelementstyle/drupalelementstyleediting';
/**
* @module drupalMedia/drupalelementstyle
*/
/**
* The Drupal Element Style plugin.
*
* This plugin is internal and it is currently only used for providing
* `data-align` support to `<drupal-media>`. However, this plugin isn't tightly
* coupled to `<drupal-media>` or `data-align`. The intent is to make this
* plugin a starting point for adding `data-align` support to other elements,
* because the `FilterAlign` filter plugin PHP code also does not limit itself
* to a specific HTML element. This could be also used for other filters to
* provide same authoring experience as `FilterAlign` without the need for
* additional JavaScript code.
*
* To be able to change element styles in the UI, the model element needs to
* have a toolbar where the element style buttons can be displayed.
*
* This plugin is inspired by the CKEditor 5 Image Style plugin.
*
* @see module:image/imagestyle~ImageStyle
* @see core/modules/ckeditor5/css/media-alignment.css
* @see module:drupalMedia/drupalmediaediting~DrupalMediaEditing
* @see module:drupalMedia/drupalmediatoolbar~DrupalMediaToolbar
*
* @private
*/
export default class DrupalElementStyle extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [DrupalElementStyleEditing, DrupalElementStyleUi];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalElementStyle';
}
}

View File

@@ -0,0 +1,135 @@
/* eslint-disable import/no-extraneous-dependencies */
import { Command } from 'ckeditor5/src/core';
import { getClosestElementWithElementStyleAttribute } from './utils';
import { groupNameToModelAttributeKey } from '../utils';
/**
* @module drupalMedia/drupalelementstyle/drupalelementstylecommand
*/
/**
* The Drupal Element style command.
*
* This is used to apply the Drupal Element Style option to supported model
* elements.
*
* @extends module:core/command~Command
*
* @private
*/
export default class DrupalElementStyleCommand extends Command {
/**
* Constructs a new object.
*
* @param {module:core/editor/editor~Editor} editor
* The editor instance.
* @param {Object<string, Drupal.CKEditor5~DrupalElementStyleDefinition>} styles
* All available Drupal Element Styles.
*/
constructor(editor, styles) {
super(editor);
this.styles = {};
Object.keys(styles).forEach((group) => {
this.styles[group] = new Map(
styles[group].map((style) => {
return [style.name, style];
}),
);
});
this.modelAttributes = [];
// eslint-disable-next-line no-restricted-syntax
for (const group of Object.keys(styles)) {
const modelAttribute = groupNameToModelAttributeKey(group);
// Generate list of model attributes.
this.modelAttributes.push(modelAttribute);
}
}
/**
* @inheritdoc
*/
refresh() {
const { editor } = this;
const element = getClosestElementWithElementStyleAttribute(
editor.model.document.selection,
editor.model.schema,
this.modelAttributes,
);
this.isEnabled = !!element;
if (this.isEnabled) {
// Assign value to be corresponding command value based on the element's modelAttribute.
this.value = this.getValue(element);
} else {
this.value = false;
}
}
/**
* Gets the command value including groups and values.
*
* @example {drupalAlign: 'left', drupalViewMode: 'full'}
*
* @param {module:engine/model/element~Element} element
* The element.
*
* @return {Object}
* The groups and values in the form of an object.
*/
getValue(element) {
const value = {};
// Get value for each of the Drupal Element Style groups.
Object.keys(this.styles).forEach((group) => {
const modelAttribute = groupNameToModelAttributeKey(group);
if (element.hasAttribute(modelAttribute)) {
value[group] = element.getAttribute(modelAttribute);
} else {
// eslint-disable-next-line no-restricted-syntax
for (const [, style] of this.styles[group]) {
// Set it to the default value.
if (style.isDefault) {
value[group] = style.name;
}
}
}
});
return value;
}
/**
* Executes the command and applies the style to the selected model element.
*
* @example
* editor.execute('drupalElementStyle', { value: 'left', group: 'align'});
*
* @param {Object} options
* The command options.
* @param {string} options.value
* The name of the style as configured in the Drupal Element style
* configuration.
* @param {string} options.group
* The group name of the drupalElementStyle.
*/
execute(options = {}) {
const {
editor: { model },
} = this;
const { value, group } = options;
const modelAttribute = groupNameToModelAttributeKey(group);
model.change((writer) => {
const element = getClosestElementWithElementStyleAttribute(
model.document.selection,
model.schema,
this.modelAttributes,
);
if (!value || this.styles[group].get(value).isDefault) {
// Remove attribute from the element.
writer.removeAttribute(modelAttribute, element);
} else {
// Set the attribute value on the element.
writer.setAttribute(modelAttribute, value, element);
}
});
}
}

Some files were not shown because too many files have changed in this diff Show More