diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 9650ea786b8d9c7b1f7d37ff0996e5ee94e65bad..c44570872dbcea7c1b7bf5de094d15fbc6154194 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -1086,6 +1086,7 @@ spacebar spagna specialchars spiffiness +splitbutton splitbuttons spreadsheetml sqlpassword diff --git a/core/modules/ckeditor5/ckeditor5.ckeditor5.yml b/core/modules/ckeditor5/ckeditor5.ckeditor5.yml index 0a19bdca9087752f2bf7bca99dbb180d0027a1e2..80e76c686c47863f594f35bad2f5037a389a33f2 100644 --- a/core/modules/ckeditor5/ckeditor5.ckeditor5.yml +++ b/core/modules/ckeditor5/ckeditor5.ckeditor5.yml @@ -263,6 +263,7 @@ ckeditor5_codeBlock: 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 diff --git a/core/modules/ckeditor5/ckeditor5.module b/core/modules/ckeditor5/ckeditor5.module index cf7efbda2174e0b334d2e81dc37d73921f545d76..89d7699a28cb1a130d8f91826dc0b9f6623bf230 100644 --- a/core/modules/ckeditor5/ckeditor5.module +++ b/core/modules/ckeditor5/ckeditor5.module @@ -22,6 +22,7 @@ use Drupal\Core\Render\Element; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Url; +use Drupal\editor\EditorInterface; use Symfony\Component\Validator\Constraints\Choice; /** @@ -648,3 +649,34 @@ function _ckeditor5_theme_css($theme = NULL): array { } return $css; } + +/** + * Implements hook_ENTITY_TYPE_presave() for editor entities. + */ +function ckeditor5_editor_presave(EditorInterface $editor) { + if ($editor->getEditor() === 'ckeditor5') { + $settings = $editor->getSettings(); + 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'], + ], + ]; + $editor->setSettings($settings); + } + } +} diff --git a/core/modules/ckeditor5/ckeditor5.post_update.php b/core/modules/ckeditor5/ckeditor5.post_update.php index 6f724b7473b472b130f572c954ebd6c9e93589dd..642673e182c3a9977fa3f3014f26c8359f734032 100644 --- a/core/modules/ckeditor5/ckeditor5.post_update.php +++ b/core/modules/ckeditor5/ckeditor5.post_update.php @@ -89,3 +89,18 @@ function ckeditor5_post_update_plugins_settings_export_order(&$sandbox = []) { 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(); + return in_array('codeBlock', $settings['toolbar']['items'], TRUE); + }); +} diff --git a/core/modules/ckeditor5/config/schema/ckeditor5.schema.yml b/core/modules/ckeditor5/config/schema/ckeditor5.schema.yml index cad0c764e7f62be63fd67a483f473a58c6d61164..82e201d415606c0d3cd1f78551ec54cd009404e9 100644 --- a/core/modules/ckeditor5/config/schema/ckeditor5.schema.yml +++ b/core/modules/ckeditor5/config/schema/ckeditor5.schema.yml @@ -141,6 +141,31 @@ ckeditor5.plugin.media_media: constraints: NotNull: [] +# Plugin \Drupal\ckeditor5\Plugin\CKEditor5Plugin\CodeBlock +ckeditor5.plugin.ckeditor5_codeBlock: + type: mapping + label: Code Block + mapping: + languages: + type: sequence + orderby: ~ + label: 'Languages' + constraints: + NotBlank: + message: "Enable at least one language, otherwise disable the Code Block plugin." + UniqueLabelInList: + labelKey: label + sequence: + type: mapping + label: 'Language' + mapping: + label: + type: label + label: 'Language label' + language: + type: string + label: 'Language key' + # Plugin \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style ckeditor5.plugin.ckeditor5_style: type: mapping diff --git a/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/CodeBlock.php b/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/CodeBlock.php new file mode 100644 index 0000000000000000000000000000000000000000..364ca1733dac8ac328eace9dab89eefb91651669 --- /dev/null +++ b/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/CodeBlock.php @@ -0,0 +1,138 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin; + +use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait; +use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault; +use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\editor\EditorInterface; + +/** + * CKEditor 5 Code Block plugin configuration. + * + * @internal + * Plugin classes are internal. + */ +class CodeBlock extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface { + + use CKEditor5PluginConfigurableTrait; + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form['languages'] = [ + '#title' => $this->t('Programming languages'), + '#type' => 'textarea', + '#description' => $this->t('A list of programming languages that will be provided in the "Code Block" dropdown. Enter one value per line, in the format key|label. Example: php|PHP.'), + ]; + if (!empty($this->configuration['languages'])) { + $as_selectors = ''; + foreach ($this->configuration['languages'] as $language) { + $as_selectors .= sprintf("%s|%s\n", $language['language'], $language['label']); + } + $form['languages']['#default_value'] = $as_selectors; + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + $form_value = $form_state->getValue('languages'); + [$styles, $unparseable_lines] = self::parseLanguagesFromValue($form_value); + if (!empty($unparseable_lines)) { + $line_numbers = array_keys($unparseable_lines); + $form_state->setError($form['languages'], $this->formatPlural( + count($unparseable_lines), + 'Line @line-number does not contain a valid value. Enter a valid language key followed by a pipe symbol and a label.', + 'Lines @line-numbers do not contain a valid value. Enter a valid language key followed by a pipe symbol and a label.', + [ + '@line-number' => reset($line_numbers), + '@line-numbers' => implode(', ', $line_numbers), + ] + )); + } + $form_state->setValue('languages', $styles); + } + + /** + * Parses the line-based (for form) Code Block configuration. + * + * @param string $form_value + * A string containing >=1 lines with on each line a language key and label. + * + * @return array + * The parsed equivalent: a list of arrays with each containing: + * - label: the label after the pipe symbol, with whitespace trimmed + * - language: the key for the language + */ + protected static function parseLanguagesFromValue(string $form_value): array { + $unparseable_lines = []; + + $lines = explode("\n", $form_value); + $languages = []; + foreach ($lines as $line) { + if (empty(trim($line))) { + continue; + } + + // Parse the line. + [$language, $label] = array_map('trim', explode('|', $line)); + + $languages[] = [ + 'label' => $label, + 'language' => $language, + ]; + } + return [$languages, $unparseable_lines]; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $this->configuration['languages'] = $form_state->getValue('languages'); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'languages' => [ + ['language' => 'plaintext', 'label' => 'Plain text'], + ['language' => 'c', 'label' => 'C'], + ['language' => 'cs', 'label' => 'C#'], + ['language' => 'cpp', 'label' => 'C++'], + ['language' => 'css', 'label' => 'CSS'], + ['language' => 'diff', 'label' => 'Diff'], + ['language' => 'html', 'label' => 'HTML'], + ['language' => 'java', 'label' => 'Java'], + ['language' => 'javascript', 'label' => 'JavaScript'], + ['language' => 'php', 'label' => 'PHP'], + ['language' => 'python', 'label' => 'Python'], + ['language' => 'ruby', 'label' => 'Ruby'], + ['language' => 'typescript', 'label' => 'TypeScript'], + ['language' => 'xml', 'label' => 'XML'], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array { + return [ + 'codeBlock' => [ + 'languages' => $this->configuration['languages'], + ], + ]; + } + +} diff --git a/core/modules/ckeditor5/tests/src/Functional/Update/CKEditor5UpdateCodeBlockConfigurationTest.php b/core/modules/ckeditor5/tests/src/Functional/Update/CKEditor5UpdateCodeBlockConfigurationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9bf6f3340399ec13366b52c9837448f58194c831 --- /dev/null +++ b/core/modules/ckeditor5/tests/src/Functional/Update/CKEditor5UpdateCodeBlockConfigurationTest.php @@ -0,0 +1,63 @@ +<?php + +namespace Drupal\Tests\ckeditor5\Functional\Update; + +use Drupal\editor\Entity\Editor; +use Drupal\FunctionalTests\Update\UpdatePathTestBase; + +/** + * @covers ckeditor5_post_update_code_block + * @group Update + * @group ckeditor5 + */ +class CKEditor5UpdateCodeBlockConfigurationTest extends UpdatePathTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setDatabaseDumpFiles() { + $this->databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.filled.standard.php.gz', + ]; + } + + /** + * Ensure default configuration for the CKEditor 5 codeBlock plugin is added. + */ + public function testUpdateCodeBlockConfigurationPostUpdate(): void { + $editor = Editor::load('full_html'); + $settings = $editor->getSettings(); + $this->assertArrayNotHasKey('ckeditor5_codeBlock', $settings['plugins']); + + $this->runUpdates(); + + $editor = Editor::load('full_html'); + $settings = $editor->getSettings(); + $this->assertArrayHasKey('ckeditor5_codeBlock', $settings['plugins']); + // @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\CodeBlock::defaultConfiguration() + $this->assertSame([ + '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'], + ], + ], $settings['plugins']['ckeditor5_codeBlock']); + } + +} diff --git a/core/modules/ckeditor5/tests/src/Kernel/ConfigurablePluginTest.php b/core/modules/ckeditor5/tests/src/Kernel/ConfigurablePluginTest.php index a786cb43325dbd016e3008ee8c293d8166a90c3c..f12a23ce6131c853c744971eb830727a147d6800 100644 --- a/core/modules/ckeditor5/tests/src/Kernel/ConfigurablePluginTest.php +++ b/core/modules/ckeditor5/tests/src/Kernel/ConfigurablePluginTest.php @@ -70,6 +70,24 @@ public function testDefaults() { 'ckeditor5_sourceEditing' => [ 'allowed_tags' => [], ], + 'ckeditor5_codeBlock' => [ + 'languages' => [ + ['language' => 'plaintext', 'label' => 'Plain text'], + ['language' => 'c', 'label' => 'C'], + ['language' => 'cs', 'label' => 'C#'], + ['language' => 'cpp', 'label' => 'C++'], + ['language' => 'css', 'label' => 'CSS'], + ['language' => 'diff', 'label' => 'Diff'], + ['language' => 'html', 'label' => 'HTML'], + ['language' => 'java', 'label' => 'Java'], + ['language' => 'javascript', 'label' => 'JavaScript'], + ['language' => 'php', 'label' => 'PHP'], + ['language' => 'python', 'label' => 'Python'], + ['language' => 'ruby', 'label' => 'Ruby'], + ['language' => 'typescript', 'label' => 'TypeScript'], + ['language' => 'xml', 'label' => 'XML'], + ], + ], 'ckeditor5_list' => [ 'reversed' => TRUE, 'startIndex' => TRUE, diff --git a/core/modules/ckeditor5/tests/src/Kernel/SmartDefaultSettingsTest.php b/core/modules/ckeditor5/tests/src/Kernel/SmartDefaultSettingsTest.php index 4508759f83fc8117f3e26b32624557ad48ac1583..bf294d83752257485d05328f206e4aa630e3857f 100644 --- a/core/modules/ckeditor5/tests/src/Kernel/SmartDefaultSettingsTest.php +++ b/core/modules/ckeditor5/tests/src/Kernel/SmartDefaultSettingsTest.php @@ -903,7 +903,26 @@ public function provider() { ['codeBlock'], ), ], - 'plugins' => $basic_html_test_case['expected_ckeditor5_settings']['plugins'], + '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'], + ], + ], + ] + $basic_html_test_case['expected_ckeditor5_settings']['plugins'], ], 'expected_superset' => '<code class="language-*">', 'expected_fundamental_compatibility_violations' => $basic_html_test_case['expected_fundamental_compatibility_violations'], @@ -1215,6 +1234,24 @@ public function provider() { ], ], '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'], + ], + ], 'ckeditor5_heading' => [ 'enabled_headings' => [ 'heading2', diff --git a/core/modules/ckeditor5/tests/src/Nightwatch/Tests/ckEditor5CodeSyntaxTest.js b/core/modules/ckeditor5/tests/src/Nightwatch/Tests/ckEditor5CodeSyntaxTest.js new file mode 100644 index 0000000000000000000000000000000000000000..f88f79f05241b1667c2bec47254a85acfeb3d1af --- /dev/null +++ b/core/modules/ckeditor5/tests/src/Nightwatch/Tests/ckEditor5CodeSyntaxTest.js @@ -0,0 +1,133 @@ +module.exports = { + '@tags': ['core', 'ckeditor5'], + before(browser) { + browser.drupalInstall({ installProfile: 'minimal' }); + }, + after(browser) { + browser.drupalUninstall(); + }, + 'Verify code block configured languages are respected': (browser) => { + browser.drupalLoginAsAdmin(() => { + browser + // Enable required modules. + .drupalRelativeURL('/admin/modules') + .click('[name="modules[ckeditor5][enable]"]') + .click('[name="modules[field_ui][enable]"]') + .submitForm('input[type="submit"]') // Submit module form. + .waitForElementVisible( + '.system-modules-confirm-form input[value="Continue"]', + ) + .submitForm('input[value="Continue"]') // Confirm installation of dependencies. + .waitForElementVisible('.system-modules', 10000) + + // Create new input format. + .drupalRelativeURL('/admin/config/content/formats/add') + .waitForElementVisible('[data-drupal-selector="edit-name"]') + .updateValue('[data-drupal-selector="edit-name"]', 'test') + .waitForElementVisible('#edit-name-machine-name-suffix') + .click( + '[data-drupal-selector="edit-editor-editor"] option[value=ckeditor5]', + ) + // Wait for CKEditor 5 settings to be visible. + .waitForElementVisible( + '[data-drupal-selector="edit-editor-settings-toolbar"]', + ) + .click('.ckeditor5-toolbar-button-sourceEditing') // Select the Source Editing button. + .keys(browser.Keys.DOWN) // Hit the down arrow key to move it to the toolbar. + // Wait for new source editing vertical tab to be present before continuing. + .waitForElementVisible( + '[href*=edit-editor-settings-plugins-ckeditor5-sourceediting]', + ) + .click('.ckeditor5-toolbar-item-codeBlock') // Select the Code Block button. + .keys(browser.Keys.DOWN) // Hit the down arrow key to move it to the toolbar. + // Wait for new code editing vertical tab to be present before continuing. + .waitForElementVisible( + '[href*=edit-editor-settings-plugins-ckeditor5-codeblock]', + ) + .click('[href*=edit-editor-settings-plugins-ckeditor5-codeblock]') + .setValue( + '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-codeblock-languages"]', + 'twig|Twig\nyml|YML', + ) + .submitForm('input[type="submit"]') + .waitForElementVisible('[data-drupal-messages]') + .assert.textContains('[data-drupal-messages]', 'Added text format') + + // Create a new content type. + .drupalRelativeURL('/admin/structure/types/add') + .waitForElementVisible('[data-drupal-selector="edit-name"]') + .updateValue('[data-drupal-selector="edit-name"]', 'test') + .waitForElementVisible('#edit-name-machine-name-suffix') // Wait for machine name to update. + .submitForm('input[type="submit"]') + .waitForElementVisible('[data-drupal-messages]') + .assert.textContains( + '[data-drupal-messages]', + 'The content type test has been added', + ) + + // Navigate to create new content. + .drupalRelativeURL('/node/add/test') + .waitForElementVisible('.ck-editor__editable') + + // Open code block dropdown, and verify that correct languages are present. + .click( + '.ck-code-block-dropdown .ck-dropdown__button .ck-splitbutton__arrow', + ) + .assert.containsText( + '.ck-code-block-dropdown .ck-dropdown__panel .ck-list__item:nth-child(1) .ck-button__label', + 'Twig', + ) + .assert.containsText( + '.ck-code-block-dropdown .ck-dropdown__panel .ck-list__item:nth-child(2) .ck-button__label', + 'YML', + ) + + // Click the first language (which should be 'Twig'). + .click( + '.ck-code-block-dropdown .ck-dropdown__panel .ck-list__item:nth-child(1) button', + ) + .waitForElementVisible('.ck-editor__main pre[data-language="Twig"]') + .keys('x') // Press 'X' to ensure there's data in CKEditor before switching to source view. + .pause(50) + + // Go into source editing and verify that correct CSS class is added. + .click('.ck-source-editing-button') + .waitForElementVisible('.ck-source-editing-area') + .assert.valueContains( + '.ck-source-editing-area textarea', + '<pre><code class="language-twig">', + ) + + // Go back into WYSIWYG mode and hit enter three times to break out of code block. + .click('.ck-source-editing-button') // Disable source editing. + .waitForElementVisible('.ck-editor__editable:not(.ck-hidden)') + .keys(browser.Keys.RIGHT) // Go to end of line. + .pause(50) + + // Hit Enter three times to break out of CKEditor's code block. + .keys(browser.Keys.ENTER) + .pause(50) + .keys(browser.Keys.ENTER) + .pause(50) + .keys(browser.Keys.ENTER) + .pause(50) + + // Open up the code syntax dropdown, and click the 2nd item (which should be 'YML'). + .click( + '.ck-code-block-dropdown .ck-dropdown__button .ck-splitbutton__arrow', + ) + .click( + '.ck-code-block-dropdown .ck-dropdown__panel .ck-list__item:nth-child(2) button', + ) + .keys('x') // Press 'X' to ensure there's data in CKEditor before switching to source view. + + // Go into source editing and verify that correct CSS class is added. + .click('.ck-source-editing-button') + .waitForElementVisible('.ck-source-editing-area') + .assert.valueContains( + '.ck-source-editing-area textarea', + '<pre><code class="language-yml">', + ); + }); + }, +}; diff --git a/core/profiles/standard/config/install/editor.editor.full_html.yml b/core/profiles/standard/config/install/editor.editor.full_html.yml index babf6b59e04d6228ced44158b5c0d284ffd8cbfe..efe9de4a6c5d0b5203d998c697c8281033236010 100644 --- a/core/profiles/standard/config/install/editor.editor.full_html.yml +++ b/core/profiles/standard/config/install/editor.editor.full_html.yml @@ -32,6 +32,50 @@ settings: - '|' - sourceEditing 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 ckeditor5_heading: enabled_headings: - heading2