From 3bd8196aed546c7fee44f57061168b1c0ba6d22c Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 18 Mar 2026 19:39:14 -0700 Subject: [PATCH 1/4] JS/JSON/CSS file support --- composer.json | 2 +- composer.lock | 12 +- src/Field.php | 228 +++++++++++++++--- .../m260220_182920_drop_cke_configs.php | 68 +++++- src/templates/_field-settings.twig | 178 +++++++++++--- src/translations/en/ckeditor.php | 6 + .../fieldsettings/dist/fieldsettings.js | 159 +++++++----- .../assets/fieldsettings/src/ConfigOptions.js | 110 +++++---- .../assets/fieldsettings/src/CssOptions.js | 40 +++ .../assets/fieldsettings/src/fieldsettings.js | 1 + 10 files changed, 609 insertions(+), 195 deletions(-) create mode 100644 src/web/assets/fieldsettings/src/CssOptions.js diff --git a/composer.json b/composer.json index bd92d5b0..ac738dc2 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "require": { "php": "^8.2", "craftcms/cms": "^5.9.0", - "craftcms/html-field": "^3.4.0", + "craftcms/html-field": "^3.5.0", "embed/embed": "^4.4", "nystudio107/craft-code-editor": ">=1.0.8 <=1.0.13 || ^1.0.16" }, diff --git a/composer.lock b/composer.lock index 8816487c..9591d85e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d36af02d77c80621f4027530db1cfec6", + "content-hash": "8006f02686b43940563c0eea04fd1ee8", "packages": [ { "name": "bacon/bacon-qr-code", @@ -676,16 +676,16 @@ }, { "name": "craftcms/html-field", - "version": "3.4.0", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/craftcms/html-field.git", - "reference": "3f23569c94d64e9054e3402447e03bd3c4c7b181" + "reference": "17721a1e2de9ea4b9f2d7ffe40332c55badb17a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/html-field/zipball/3f23569c94d64e9054e3402447e03bd3c4c7b181", - "reference": "3f23569c94d64e9054e3402447e03bd3c4c7b181", + "url": "https://api.github.com/repos/craftcms/html-field/zipball/17721a1e2de9ea4b9f2d7ffe40332c55badb17a4", + "reference": "17721a1e2de9ea4b9f2d7ffe40332c55badb17a4", "shasum": "" }, "require": { @@ -724,7 +724,7 @@ "rss": "https://github.com/craftcms/html-field/commits/main.atom", "source": "https://github.com/craftcms/html-field" }, - "time": "2025-04-30T16:54:07+00:00" + "time": "2026-03-19T02:32:56+00:00" }, { "name": "craftcms/plugin-installer", diff --git a/src/Field.php b/src/Field.php index 1297e893..a97f97db 100644 --- a/src/Field.php +++ b/src/Field.php @@ -405,6 +405,64 @@ private static function adjustFieldValues( } } + /** @var array */ + private static array $cssFileContents = []; + /** @var array */ + private static array $jsonFileContents = []; + /** @var array */ + private static array $jsFileContents = []; + + private static function cssFileContents(string $file): ?string + { + if (!isset(self::$cssFileContents[$file])) { + $path = self::configFilePath($file); + if (file_exists($path)) { + self::$cssFileContents[$file] = file_get_contents($path); + } else { + self::$cssFileContents[$file] = false; + Craft::warning("Could not load CKEditor CSS file \"$file\".", __METHOD__); + } + } + return self::$cssFileContents[$file] ?: null; + } + + private static function jsonFileContents(string $file): array + { + if (!isset(self::$jsonFileContents[$file])) { + $path = self::configFilePath($file); + try { + self::$jsonFileContents[$file] = Json::decodeFromFile($path) ?? []; + } catch (InvalidArgumentException $e) { + Craft::warning("Could not decode JSON from CKEditor config file \"$file\": " . $e->getMessage(), __METHOD__); + self::$jsonFileContents[$file] = []; + } + } + + return self::$jsonFileContents[$file]; + } + + private static function jsFileContents(string $file): ?string + { + if (!isset(self::$jsFileContents[$file])) { + $path = self::configFilePath($file); + if (file_exists($path)) { + self::$jsFileContents[$file] = file_get_contents($path); + } else { + self::$jsFileContents[$file] = false; + Craft::warning("Could not load CKEditor config JS file \"$file\".", __METHOD__); + } + } + return self::$jsFileContents[$file] ?: null; + } + + /** + * @since 5.3.0 + */ + public static function configFilePath(string $file): string + { + return sprintf('%s/ckeditor/%s', Craft::$app->getPath()->getConfigPath(), $file); + } + /** * Normalizes an entry type into a `craft\ckeditor\models\EntryType` object. * @@ -471,12 +529,24 @@ public static function entryType(EntryType|CkeEntryType|string|array $entryType) */ public ?string $js = null; + /** + * @var string|null The config file that should be used to configure CKEditor. + * @since 5.3.0 + */ + public ?string $jsFile = null; + /** * @var string|null CSS styles that should be registered for the field. * @since 5.0.0 */ public ?string $css = null; + /** + * @var string|null The CSS file that should be used to style CKEditor contents. + * @since 5.3.0 + */ + public ?string $cssFile = null; + /** * @var int|null The total number of words allowed. * @since 3.5.0 @@ -604,30 +674,51 @@ public function __construct($config = []) $config['ckeConfig'], ); - if (!array_key_exists('options', $config)) { - // Only use `json` or `js`, not both - if (!empty($config['json'])) { - unset($config['js']); - $config['json'] = trim($config['json']); - if ($config['json'] === '' || preg_match('/^\{\s*\}$/', $config['json'])) { - unset($config['json']); - } - } else { - unset($config['json']); - if (isset($config['js'])) { + if (isset($config['configMode'])) { + switch ($config['configMode']) { + case 'js': $config['js'] = trim($config['js']); if ($config['js'] === '' || preg_match('/^return\s*\{\s*\}$/', $config['js'])) { unset($config['js']); } - } + unset($config['json'], $config['jsFile']); + break; + case 'file': + if (empty($config['jsFile'])) { + $config['jsFile'] = null; + } + unset($config['json'], $config['js']); + break; + default: + $config['json'] = trim($config['json']); + if ($config['json'] === '' || preg_match('/^\{\s*\}$/', $config['json'])) { + unset($config['json']); + } + unset($config['js'], $config['file']); + break; } + + unset($config['configMode']); } - if (isset($config['css'])) { - $config['css'] = trim($config['css']); - if ($config['css'] === '') { - unset($config['css']); + if (isset($config['cssMode'])) { + switch ($config['cssMode']) { + case 'file': + if (empty($config['cssFile'])) { + $config['cssFile'] = null; + } + unset($config['css']); + break; + default: + $config['css'] = trim($config['css']); + if ($config['css'] === '') { + unset($config['css']); + } + unset($config['file']); + break; } + + unset($config['cssMode']); } if (isset($config['entryTypes']) && $config['entryTypes'] === '') { @@ -994,11 +1085,23 @@ private function settingsHtml(bool $readOnly): string $jsonSchemaUri = sprintf('https://craft-code-editor.com/%s', $view->namespaceInputId('config-options-json')); + $configMode = match(true) { + !empty($this->js) => 'js', + !empty($this->jsFile) => 'file', + default => 'json', + }; + + $cssMode = match(true) { + !empty($this->cssFile) => 'file', + default => 'css', + }; + return $view->renderTemplate('ckeditor/_field-settings.twig', [ 'field' => $this, 'importStatements' => CkeditorConfig::getImportStatements(), 'toolbarBuilderId' => $view->namespaceInputId('toolbar-builder'), 'configOptionsId' => $view->namespaceInputId('config-options'), + 'cssOptionsId' => $view->namespaceInputId('css-options'), 'toolbarItems' => CkeditorConfig::normalizeToolbarItems(CkeditorConfig::$toolbarItems), 'plugins' => CkeditorConfig::getAllPlugins(), 'jsonSchema' => CkeditorConfigSchema::create(), @@ -1006,6 +1109,18 @@ private function settingsHtml(bool $readOnly): string 'advanceLinkOptions' => CkeditorConfig::advanceLinkOptions(), 'entryTypes' => $this->getEntryTypes(), 'userGroupOptions' => $userGroupOptions, + 'jsFileOptions' => $this->configOptions( + dir: 'ckeditor', + only: ['*.json', '*.js'], + includeDefault: false, + includeExtensions: true, + ), + 'cssFileOptions' => $this->configOptions( + dir: 'ckeditor', + only: ['*.css'], + includeDefault: false, + includeExtensions: true, + ), 'purifierConfigOptions' => $this->configOptions('htmlpurifier'), 'baseIconsUrl' => "$bundle->baseUrl/images", 'volumeOptions' => $volumeOptions, @@ -1016,6 +1131,8 @@ private function settingsHtml(bool $readOnly): string 'value' => null, ], ], $transformOptions), + 'configMode' => $configMode, + 'cssMode' => $cssMode, 'readOnly' => $readOnly, ]); } @@ -1369,23 +1486,6 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat ]); $this->trigger(self::EVENT_MODIFY_CONFIG, $event); - if (isset($this->options)) { - // translate the placeholder text - if (isset($this->options['placeholder']) && is_string($this->options['placeholder'])) { - $this->options['placeholder'] = Craft::t('site', $this->options['placeholder']); - } - - $configOptionsJs = Json::encode($this->options); - } elseif (isset($this->js)) { - $configOptionsJs = << { - $this->js -})() -JS; - } else { - $configOptionsJs = '{}'; - } - $removePlugins = Collection::empty(); // remove MediaEmbedToolbar for now @@ -1435,17 +1535,28 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat $importCompliantUiLanguage = BaseCkeditorPackageAsset::getImportCompliantLanguage(BaseCkeditorPackageAsset::uiLanguage()); $uiTranslationImport = "import coreTranslations from 'ckeditor5/translations/$importCompliantUiLanguage.js';"; - $view->registerScriptWithVars(fn($baseConfigJs, $toolbarJs, $languageJs, $showWordCountJs, $wordLimitJs, $characterLimitJs, $imageMode) => <<configJs() ?? '{}'; + + $view->registerScriptWithVars(fn( + $baseConfigJs, + $toolbarJs, + $languageJs, + $showWordCountJs, + $wordLimitJs, + $characterLimitJs, + $imageMode, + ) => << { let instance; + const customConfig = $configJs; const config = Object.assign({ translations: [coreTranslations], language: $languageJs, - }, $baseConfigJs, $configOptionsJs, { + }, $baseConfigJs, customConfig, { plugins: $configPlugins, removePlugins: [] }); @@ -1459,7 +1570,7 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat // special case for heading config, because of the Heading Levels // see https://github.com/craftcms/ckeditor/issues/431 const baseHeadings = $baseConfigJs?.heading?.options; - const configOptionHeadings = $configOptionsJs?.heading?.options; + const configOptionHeadings = customConfig?.heading?.options; const nativeHeadingModels = ['paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6']; if (baseHeadings && configOptionHeadings && baseHeadings != configOptionHeadings) { @@ -1572,8 +1683,8 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat ]); } - if ($this->css) { - $css = $this->css; + $css = $this->css(); + if ($css) { $imports = []; preg_match_all('/@import .+;?/m', $css, $importMatches); for ($i = 0; $i < count($importMatches[0]); $i++) { @@ -1601,6 +1712,47 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat ]); } + private function configJs(): ?string + { + if (isset($this->jsFile) && strtolower(pathinfo($this->jsFile, PATHINFO_EXTENSION)) === 'json') { + $this->options = self::jsonFileContents($this->jsFile); + } + + if (isset($this->options)) { + // translate the placeholder text + if (isset($this->options['placeholder']) && is_string($this->options['placeholder'])) { + $this->options['placeholder'] = Craft::t('site', $this->options['placeholder']); + } + + return Json::encode($this->options); + } + + if (isset($this->jsFile)) { + $js = self::jsFileContents($this->jsFile); + } else { + $js = $this->js; + } + + if ($js === null) { + return '{}'; + } + + return << { + $js +})() +JS; + } + + private function css(): ?string + { + if (isset($this->cssFile)) { + return self::cssFileContents($this->cssFile); + } + + return $this->css; + } + /** * @inheritdoc */ diff --git a/src/migrations/m260220_182920_drop_cke_configs.php b/src/migrations/m260220_182920_drop_cke_configs.php index 8b15406c..b438f5a5 100644 --- a/src/migrations/m260220_182920_drop_cke_configs.php +++ b/src/migrations/m260220_182920_drop_cke_configs.php @@ -8,6 +8,7 @@ use craft\db\Query; use craft\db\Table; use craft\helpers\ArrayHelper; +use craft\helpers\FileHelper; use craft\helpers\Json; use craft\helpers\ProjectConfig; use Throwable; @@ -27,20 +28,77 @@ public function safeUp(): bool $ckeConfigs = $projectConfig->get('ckeditor.configs') ?? []; $entriesService = Craft::$app->getEntries(); - foreach ($fieldConfigs as $fieldPath => $fieldConfig) { + // Create config JS/JSON and CSS files, for any configs that are used by 2+ fields + $configCounts = []; + $configBaseNames = []; + foreach ($fieldConfigs as $fieldPath => &$fieldConfig) { if (empty($fieldConfig['settings'])) { continue; } - $settings = ProjectConfig::unpackAssociativeArrays($fieldConfig['settings']); + $fieldConfig['settings'] = ProjectConfig::unpackAssociativeArrays($fieldConfig['settings']); // if we've already lost the ckeConfig and we have some new properties (e.g. toolbar) // we need to get the "old" field's settings directly from the database (not from the memoized array) - if (!isset($settings['ckeConfig']) && isset($settings['toolbar'])) { + if (!isset($fieldConfig['settings']['ckeConfig']) && isset($fieldConfig['settings']['toolbar'])) { $fieldUid = str_replace('fields.', '', $fieldPath); - $settings = $this->getOldFieldSettings($fieldUid) ?? $settings; + $fieldConfig['settings'] = $this->getOldFieldSettings($fieldUid) ?? $fieldConfig['settings']; + } + + if (!isset($fieldConfig['settings']['ckeConfig'])) { + continue; + } + + if (!isset($configCounts[$fieldConfig['settings']['ckeConfig']])) { + $configCounts[$fieldConfig['settings']['ckeConfig']] = 1; + } else { + $configCounts[$fieldConfig['settings']['ckeConfig']]++; + } + } + unset($fieldConfig); + + foreach ($configCounts as $ckeConfigUid => $count) { + if ($count < 2 || !isset($ckeConfigs[$ckeConfigUid])) { + continue; + } + + $ckeConfig = &$ckeConfigs[$ckeConfigUid]; + $baseName = str_replace(' ', '-', $ckeConfig['name'] ?? $ckeConfigUid); + if (isset($configBaseNames[$baseName])) { + $baseName .= sprintf('-%s', mt_rand()); + } + $configBaseNames[$baseName] = true; + + if (isset($ckeConfig['options']) || isset($ckeConfig['js'])) { + if (isset($ckeConfig['options'])) { + $file = "$baseName.json"; + Json::encodeToFile(Field::configFilePath($file), $ckeConfig['options']); + } else { + $file = "$baseName.js"; + FileHelper::writeToFile(Field::configFilePath($file), $ckeConfig['js']); + } + + $ckeConfig['jsFile'] = $file; + unset($ckeConfig['options'], $ckeConfig['js']); + } + + if (isset($ckeConfig['css'])) { + $file = "$baseName.css"; + FileHelper::writeToFile(Field::configFilePath($file), $ckeConfig['css']); + $ckeConfig['cssFile'] = $file; + unset($ckeConfig['css']); + } + + unset($ckeConfig); + } + + // Now update the field settings + foreach ($fieldConfigs as $fieldPath => $fieldConfig) { + if (empty($fieldConfig['settings'])) { + continue; } + $settings = $fieldConfig['settings']; $ckeConfigUid = ArrayHelper::remove($settings, 'ckeConfig'); $expandEntryButtons = ArrayHelper::remove($settings, 'expandEntryButtons') ?? false; @@ -69,7 +127,9 @@ public function safeUp(): bool 'advancedLinkFields' => $ckeConfig['advancedLinkFields'] ?? [], 'options' => $ckeConfig['options'] ?? null, 'js' => $ckeConfig['js'] ?? null, + 'jsFile' => $ckeConfig['jsFile'] ?? null, 'css' => $ckeConfig['css'] ?? null, + 'cssFile' => $ckeConfig['cssFile'] ?? null, 'entryTypes' => $ckeConfigs['entryTypes'] ?? [], // in case m250523_124328_v5_upgrade already ran 'fullGraphqlData' => false, ]; diff --git a/src/templates/_field-settings.twig b/src/templates/_field-settings.twig index 7ddd5d07..7be4d82f 100644 --- a/src/templates/_field-settings.twig +++ b/src/templates/_field-settings.twig @@ -277,38 +277,49 @@ }), id: 'config-options', field, - errors: field.getErrors('json')|merge(field.getErrors('js')), + errors: [ + ...field.getErrors('json'), + ...field.getErrors('js'), + ...field.getErrors('jsFile'), + ], data: {'error-key': 'json'}, } %} {% block input %} {% import 'codeeditor/codeEditor.twig' as codeEditor %} - {% set lang = field.js ? 'js' : 'json' %} - {% set isJson = lang == 'json' %} - {% set isJs = lang == 'js' %} + {% import '_includes/forms.twig' as forms %}
-
+
{{ tag('button', { type: 'button', - class: ['btn', 'small', isJson ? 'active']|filter, - aria: {pressed: isJson ? 'true'}|filter, - data: {language: 'json'}, + class: ['btn', 'small', configMode == 'json' ? 'active']|filter, + aria: {pressed: configMode == 'json' ? 'true'}|filter, + data: {mode: 'json'}, text: 'JSON', }) }} {{ tag('button', { type: 'button', - class: ['btn', 'small', isJs ? 'active' : null]|filter, - aria: {pressed: isJs ? 'true'}|filter, - data: {language: 'js'}, + class: ['btn', 'small', configMode == 'js' ? 'active' : null]|filter, + aria: {pressed: configMode == 'js' ? 'true'}|filter, + data: {mode: 'js'}, text: 'JavaScript', }) }} + {{ tag('button', { + type: 'button', + class: ['btn', 'small', configMode == 'file' ? 'active' : null]|filter, + aria: {pressed: configMode == 'file' ? 'true'}|filter, + data: {mode: 'file'}, + text: 'File', + }) }}
+
{% tag 'div' with { id: 'config-options-json-container', class: { - hidden: not isJson, + 'mt-s': true, + hidden: configMode != 'json', disabled: readOnly, }|filter|keys, } %} @@ -317,7 +328,7 @@ { id: 'config-options-json', name: 'json', - value: isJson ? (field.json ?? "{\n \n}"), + value: field.json ?? "{\n \n}", }, 'ckeditor:EditorConfigJson', baseMonacoOptions|merge({ @@ -330,7 +341,8 @@ {% tag 'div' with { id: 'config-options-js-container', class: { - hidden: not isJs, + 'mt-s': true, + hidden: configMode != 'js', disabled: readOnly, }|filter|keys, } %} @@ -338,7 +350,7 @@ { id: 'config-options-js', name: 'js', - value: isJs ? (field.js ?? "return {\n \n}"), + value: field.js ?? "return {\n \n}", }, 'ckeditor:EditorConfigJs', baseMonacoOptions|merge({ @@ -347,17 +359,131 @@ baseCodeEditorOptions|merge({wrapperClass: wrapperClasses|join(' ')}), ) }} {% endtag %} + + {% tag 'div' with { + id: 'config-options-file-container', + class: { + 'mt-s': true, + hidden: configMode != 'file', + disabled: readOnly, + }|filter|keys, + } %} + {% if jsFileOptions|length %} +

+ {{ 'Choose a JS/JSON file within `config/ckeditor/`.'|t('ckeditor')|md(inlineOnly=true) }} +

+ {{ forms.select({ + id: 'config-options-file', + name: 'jsFile', + options: jsFileOptions, + value: field.jsFile, + }) }} + {% else %} +

+ {{ 'No JS/JSON files exist within `config/ckeditor/`.'|t('ckeditor')|md(inlineOnly=true) }} +

+ {% endif %} + {% endtag %} + {% endblock %} + {% endembed %} + + {% embed '_includes/forms/field.twig' with { + label: 'Custom Styles'|t('ckeditor'), + instructions: 'Define CSS styles that should be registered for editors, such as [style classes]({url}).'|t('ckeditor', { + url: 'https://ckeditor.com/docs/ckeditor5/latest/features/style.html', + }), + id: 'css', + field, + errors: [ + ...field.getErrors('css'), + ...field.getErrors('cssFile'), + ], + data: {'error-key': 'css'}, + } %} + {% block input %} + {% import 'codeeditor/codeEditor.twig' as codeEditor %} + {% import '_includes/forms.twig' as forms %} + +
+
+ {{ tag('button', { + type: 'button', + class: ['btn', 'small', cssMode == 'css' ? 'active']|filter, + aria: {pressed: cssMode == 'css' ? 'true'}|filter, + data: {mode: 'css'}, + text: 'CSS', + }) }} + {{ tag('button', { + type: 'button', + class: ['btn', 'small', cssMode == 'file' ? 'active' : null]|filter, + aria: {pressed: cssMode == 'file' ? 'true'}|filter, + data: {mode: 'file'}, + text: 'File', + }) }} +
+ +
+ + {% tag 'div' with { + id: 'css-options-css-container', + class: { + 'mt-s': true, + hidden: cssMode != 'css', + disabled: readOnly, + }|filter|keys, + } %} + {% set wrapperClasses = wrapperClasses|push(field.hasErrors('css') ? 'has-errors') %} + {{ codeEditor.textarea( + { + id: 'css-options-css', + name: 'css', + value: cssMode == 'css' ? field.css, + }, + 'CodeEditor', + baseMonacoOptions|merge({ + language: 'css', + }), + baseCodeEditorOptions|merge({wrapperClass: wrapperClasses|join(' ')}), + ) }} + {% endtag %} + + {% tag 'div' with { + id: 'css-options-file-container', + class: { + 'mt-s': true, + hidden: cssMode != 'file', + disabled: readOnly, + }|filter|keys, + } %} + {% if cssFileOptions|length %} +

+ {{ 'Choose a CSS file within `config/ckeditor/`.'|t('ckeditor')|md(inlineOnly=true) }} +

+ {{ forms.select({ + id: 'css-options-file', + name: 'cssFile', + options: cssFileOptions, + value: field.cssFile, + }) }} + {% else %} +

+ {{ 'No CSS files exist within `config/ckeditor/`.'|t('ckeditor')|md(inlineOnly=true) }} +

+ {% endif %} + {% endtag %} {% endblock %} {% endembed %} - {% set wrapperClasses = wrapperClasses|push(readOnly ? 'disabled') %} - {{ codeEditor.textareaField( - { - label: 'Custom Styles'|t('ckeditor'), - instructions: 'Define CSS styles that should be registered for editors, such as [style classes]({url}).'|t('ckeditor', { - url: 'https://ckeditor.com/docs/ckeditor5/latest/features/style.html', - }), - id: 'css', - name: 'css', - value: field.css, - }, - 'CodeEditor', - baseMonacoOptions|merge({ - language: 'css', - }), - baseCodeEditorOptions|merge({wrapperClass: wrapperClasses|join(' ')}), - ) }} - {{ forms.lightswitchField({ label: "Purify HTML"|t('ckeditor'), instructions: 'Removes any potentially-malicious code on save, by running the submitted data through {link}.'|t('ckeditor', { diff --git a/src/translations/en/ckeditor.php b/src/translations/en/ckeditor.php index 36d5eb6c..ad4b1165 100644 --- a/src/translations/en/ckeditor.php +++ b/src/translations/en/ckeditor.php @@ -5,7 +5,11 @@ 'Advanced Link Fields' => 'Advanced Link Fields', 'Advanced' => 'Advanced', 'Available Transforms' => 'Available Transforms', + 'CKEditor config option mode' => 'CKEditor config option mode', + 'CSS mode' => 'CSS mode', 'Changing this may result in data loss.' => 'Changing this may result in data loss.', + 'Choose a CSS file within `config/ckeditor/`.' => 'Choose a CSS file within `config/ckeditor/`.', + 'Choose a JS/JSON file within `config/ckeditor/`.' => 'Choose a JS/JSON file within `config/ckeditor/`.', 'Choose which Assets field should be used to store images, from the selected entry types.' => 'Choose which Assets field should be used to store images, from the selected entry types.', 'Choose which heading levels should be available to this field.' => 'Choose which heading levels should be available to this field.', 'Column Type' => 'Column Type', @@ -33,6 +37,8 @@ 'Link to the current site' => 'Link to the current site', 'Links' => 'Links', 'Nested entries' => 'Nested entries', + 'No CSS files exist within `config/ckeditor/`.' => 'No CSS files exist within `config/ckeditor/`.', + 'No JS/JSON files exist within `config/ckeditor/`.' => 'No JS/JSON files exist within `config/ckeditor/`.', 'No entry types with an Assets field have been selected yet.' => 'No entry types with an Assets field have been selected yet.', 'No transform' => 'No transform', 'Purify HTML' => 'Purify HTML', diff --git a/src/web/assets/fieldsettings/dist/fieldsettings.js b/src/web/assets/fieldsettings/dist/fieldsettings.js index a241dcf9..9c54b5f5 100644 --- a/src/web/assets/fieldsettings/dist/fieldsettings.js +++ b/src/web/assets/fieldsettings/dist/fieldsettings.js @@ -35,17 +35,17 @@ const ToolbarBuilder = Garnish.Base.extend({ const h = a.ui.componentFactory; for (const i of h.names()) this.components[i] = h.create(i); - const d = JSON.parse(this.$container.attr("data-available-items")); - for (let i = 0; i < d.length; i++) { - const s = d[i]; + const l = JSON.parse(this.$container.attr("data-available-items")); + for (let i = 0; i < l.length; i++) { + const s = l[i]; if (s.length > 1) { - const l = this.value.findIndex( + const d = this.value.findIndex( (c) => s.some((f) => f.button === c) ); - if (l !== -1) { + if (d !== -1) { for (let c = 0; c < s.length; c++) - if (this.value[l + c] !== s[c].button) { - d.splice(i, 1, ...s.map((f) => [f])), i += s.length - 1; + if (this.value[d + c] !== s[c].button) { + l.splice(i, 1, ...s.map((f) => [f])), i += s.length - 1; break; } } @@ -56,10 +56,10 @@ const ToolbarBuilder = Garnish.Base.extend({ helper: (i) => { const s = $( '
' - ), l = $( + ), d = $( '
' ).appendTo(s); - return i.appendTo(l), s; + return i.appendTo(d), s; }, moveHelperToCursor: !0, onDragStart: () => { @@ -77,8 +77,8 @@ const ToolbarBuilder = Garnish.Base.extend({ if (this.draggingSeparator) i.css("visibility", ""); else { - const s = Craft.orientation === "ltr" ? "margin-right" : "margin-left", l = -1 * i.outerWidth(); - i.stop().velocity({ [s]: l }, 200, () => { + const s = Craft.orientation === "ltr" ? "margin-right" : "margin-left", d = -1 * i.outerWidth(); + i.stop().velocity({ [s]: d }, 200, () => { i.addClass("hidden"); }); } @@ -98,10 +98,10 @@ const ToolbarBuilder = Garnish.Base.extend({ if (this.draggingSeparator) s = this.renderSeparator(); else { - const l = i.data("componentNames"); - s = this.renderComponentGroup(l); - for (const c of l) { - const f = d.flat().find(({ button: g }) => g === c); + const d = i.data("componentNames"); + s = this.renderComponentGroup(d); + for (const c of d) { + const f = l.flat().find(({ button: g }) => g === c); f && f.configOption && e.addSetting(f.configOption); } } @@ -112,51 +112,51 @@ const ToolbarBuilder = Garnish.Base.extend({ if (!this.draggingSourceItem) { const s = $(i.data("sourceItem")); if (i.remove(), this.drag.$draggee = i = s, !this.draggingSeparator) - for (const l of s.data("componentNames")) { - const c = d.flat().find(({ button: f }) => f === l); + for (const d of s.data("componentNames")) { + const c = l.flat().find(({ button: f }) => f === d); c && c.configOption && e.removeSetting(c.configOption); } } if (!this.draggingSeparator) { i.removeClass("hidden"); - const s = Craft.orientation === "ltr" ? "margin-right" : "margin-left", l = i.css(s); + const s = Craft.orientation === "ltr" ? "margin-right" : "margin-left", d = i.css(s); i.css(s, ""); const c = i.css(s); - i.css(s, l), i.stop().velocity({ [s]: c }, 200, () => { + i.css(s, d), i.stop().velocity({ [s]: c }, 200, () => { i.css(s, ""); }); } } this.drag.returnHelpersToDraggees(), this.$items = this.$targetContainer.children(), this.value = []; for (const s of this.$items.toArray()) { - const l = $(s); - l.hasClass("ckeditor-tb--separator") ? this.value.push("|") : this.value.push(...l.data("componentNames")); + const d = $(s); + d.hasClass("ckeditor-tb--separator") ? this.value.push("|") : this.value.push(...d.data("componentNames")); } this.$input.val(JSON.stringify(this.value)); } }); const u = {}; - for (let i of d) { + for (let i of l) { const s = this.renderComponentGroup(i); - s && (s.appendTo(this.$sourceContainer), u[i.map((l) => l.button).join(",")] = s[0], this.value.includes(i[0].button) && s.addClass("hidden")); + s && (s.appendTo(this.$sourceContainer), u[i.map((d) => d.button).join(",")] = s[0], this.value.includes(i[0].button) && s.addClass("hidden")); } u["|"] = this.renderSeparator().appendTo( this.$sourceContainer )[0], this.$items = $(); for (let i = 0; i < this.value.length; i++) { const s = this.value[i]; - let l, c; + let d, c; if (s === "|") - l = this.renderSeparator().appendTo(this.$targetContainer), c = "|"; + d = this.renderSeparator().appendTo(this.$targetContainer), c = "|"; else { - const f = d.find( + const f = l.find( (g) => g.some((p) => p.button === s) ); - if (!f || (l = this.renderComponentGroup(f), !l)) + if (!f || (d = this.renderComponentGroup(f), !d)) continue; - l.appendTo(this.$targetContainer), c = f.map((g) => g.button).join(","), i += f.length - 1; + d.appendTo(this.$targetContainer), c = f.map((g) => g.button).join(","), i += f.length - 1; } - l.data("sourceItem", u[c]), this.$items = this.$items.add(l); + d.data("sourceItem", u[c]), this.$items = this.$items.add(d); } }).catch(console.error); }, @@ -175,8 +175,8 @@ const ToolbarBuilder = Garnish.Base.extend({ let a; try { a = this.renderComponent(r); - } catch (d) { - console.warn(d); + } catch (l) { + console.warn(l); continue; } e.push(a); @@ -244,45 +244,56 @@ const ToolbarBuilder = Garnish.Base.extend({ */ const ConfigOptions = Garnish.Base.extend({ jsonSchemaUri: null, - language: null, + mode: null, + lastCodeMode: null, $container: null, $jsonContainer: null, $jsContainer: null, + $fileContainer: null, jsonEditor: null, jsEditor: null, defaults: null, - init: function(id, jsonSchemaUri) { - this.jsonSchemaUri = jsonSchemaUri, this.$container = $(`#${id}`), this.$jsonContainer = $(`#${id}-json-container`), this.$jsContainer = $(`#${id}-js-container`), this.jsonEditor = window.monacoEditorInstances[`${id}-json`], this.jsEditor = window.monacoEditorInstances[`${id}-js`]; - const $languagePicker = this.$container.children(".btngroup"); - this.$jsonContainer.hasClass("hidden") ? this.language = "js" : this.language = "json", this.defaults = {}; - let lastJsValue = null; - new Craft.Listbox($languagePicker, { + init: function(id, jsonSchemaUri, mode) { + this.jsonSchemaUri = jsonSchemaUri, this.mode = mode, this.mode !== "file" && (this.lastCodeMode = mode), this.$container = $(`#${id}`), this.$ckeConfigModeInput = $(`#${id}-mode`), this.$jsonContainer = $(`#${id}-json-container`), this.$jsContainer = $(`#${id}-js-container`), this.$fileContainer = $(`#${id}-file-container`), this.jsonEditor = window.monacoEditorInstances[`${id}-json`], this.jsEditor = window.monacoEditorInstances[`${id}-js`]; + const $modePicker = this.$container.children(".btngroup"); + this.defaults = {}; + const $containers = this.$jsonContainer.add(this.$jsContainer).add(this.$fileContainer); + new Craft.Listbox($modePicker, { onChange: (t) => { - switch (this.language = t.data("language"), this.language) { + switch (this.mode = t.data("mode"), this.$ckeConfigModeInput.val(this.mode), $containers.addClass("hidden"), this.mode) { case "json": - if (lastJsValue = this.jsEditor.getModel().getValue(), this.jsContainsFunctions(lastJsValue) && !confirm( - Craft.t( - "ckeditor", - "Your JavaScript config contains functions. If you switch to JSON, they will be lost. Would you like to continue?" - ) - )) { - $languagePicker.data("listbox").$options.not('[data-language="json"]').trigger("click"); - break; - } - this.$jsonContainer.removeClass("hidden"), this.$jsContainer.addClass("hidden"); - const e = this.js2json(lastJsValue); - lastJsValue = null, this.jsonEditor.getModel().setValue(e || `{ + if (this.lastCodeMode === "js") { + const e = this.jsEditor.getModel().getValue(); + if (this.jsContainsFunctions(e) && !confirm( + Craft.t( + "ckeditor", + "Your JavaScript config contains functions. If you switch to JSON, they will be lost. Would you like to continue?" + ) + )) { + $modePicker.data("listbox").$options.filter('[data-mode="js"]').trigger("click"); + break; + } + const n = this.js2json(e); + this.jsonEditor.getModel().setValue(n || `{ }`), this.jsEditor.getModel().setValue(""); + } + this.$jsonContainer.removeClass("hidden"); break; case "js": - this.$jsonContainer.addClass("hidden"), this.$jsContainer.removeClass("hidden"); - let n; - lastJsValue !== null ? (n = lastJsValue, lastJsValue = null) : n = this.json2js(this.jsonEditor.getModel().getValue()), this.jsEditor.getModel().setValue(n || `return { + if (this.lastCodeMode === "json") { + const e = this.jsonEditor.getModel().getValue(), n = this.json2js(e); + this.jsEditor.getModel().setValue(n || `return { }`), this.jsonEditor.getModel().setValue(""); + } + this.$jsContainer.removeClass("hidden"); + break; + case "file": + this.$fileContainer.removeClass("hidden"); break; } + this.mode !== "file" && (this.lastCodeMode = this.mode); } }), this.jsonEditor.onDidPaste((ev) => { const pastedContent = this.jsonEditor.getModel().getValueInRange(ev.range); @@ -303,7 +314,7 @@ const ConfigOptions = Garnish.Base.extend({ }, getConfig: function() { let t; - if (this.language === "json") + if (this.mode === "json") t = Craft.trim(this.jsonEditor.getModel().getValue()) || "{}"; else { const e = Craft.trim(this.jsEditor.getModel().getValue()); @@ -319,7 +330,7 @@ const ConfigOptions = Garnish.Base.extend({ }, setConfig: function(t) { const e = this.config2json(t); - if (this.language === "json") + if (this.mode === "json") this.jsonEditor.getModel().setValue(e); else { const n = this.json2js(e); @@ -430,6 +441,33 @@ const ConfigOptions = Garnish.Base.extend({ } else typeof t == "string" && !t.match(/[\r\n']/) ? n = `'${t}'` : n = JSON.stringify(t); return n; } +}); +/** + * @link https://craftcms.com/ + * @copyright Copyright (c) Pixel & Tonic, Inc. + * @license GPL-3.0-or-later + */ +const CssOptions = Garnish.Base.extend({ + mode: null, + $container: null, + $cssContainer: null, + $fileContainer: null, + init: function(t, e) { + this.mode = e, this.$container = $(`#${t}`), this.$ckeConfigModeInput = $(`#${t}-mode`), this.$cssContainer = $(`#${t}-css-container`), this.$fileContainer = $(`#${t}-file-container`); + const n = this.$container.children(".btngroup"), o = this.$cssContainer.add(this.$fileContainer); + new Craft.Listbox(n, { + onChange: (r) => { + switch (this.mode = r.data("mode"), this.$ckeConfigModeInput.val(this.mode), o.addClass("hidden"), this.mode) { + case "css": + this.$cssContainer.removeClass("hidden"); + break; + case "file": + this.$fileContainer.removeClass("hidden"); + break; + } + } + }); + } }), CkeEntryTypeSelectInput = Craft.EntryTypeSelectInput.extend({ init: function(t = {}) { this.base(Object.assign({}, Craft.EntryTypeSelectInput.defaults, t)); @@ -454,8 +492,8 @@ const ConfigOptions = Garnish.Base.extend({ e ); o.on("show", () => { - let h = o.$trigger.parents(".chip"), d = this.getConfigFromComponent(h); - o.toggleItem(r, !d.expanded), o.toggleItem(a, d.expanded); + let h = o.$trigger.parents(".chip"), l = this.getConfigFromComponent(h); + o.toggleItem(r, !l.expanded), o.toggleItem(a, l.expanded); }), this.applyIndicators(t, this.getConfig(e)), this.base(t); }, async applyConfigChange(t, e, n) { @@ -478,12 +516,12 @@ const ConfigOptions = Garnish.Base.extend({ throw Craft.cp.displayError((i = (u = s == null ? void 0 : s.response) == null ? void 0 : u.data) == null ? void 0 : i.message), s; } let o = t.find(".indicators"); - const r = this.getInput(t), a = $(n.chip).find(".indicators"), h = this.getInput($(n.chip)), d = this.getConfig(h); + const r = this.getInput(t), a = $(n.chip).find(".indicators"), h = this.getInput($(n.chip)), l = this.getConfig(h); if (o.length == 0) { const s = t.find(".chip-label"); o = $('
').appendTo(s); } - o.replaceWith(a), this.updateConfig(r, d); + o.replaceWith(a), this.updateConfig(r, l); }, updateConfig: function(t, e) { t.val(JSON.stringify(e)); @@ -510,5 +548,6 @@ const ConfigOptions = Garnish.Base.extend({ export { CkeEntryTypeSelectInput, ConfigOptions, + CssOptions, ToolbarBuilder }; diff --git a/src/web/assets/fieldsettings/src/ConfigOptions.js b/src/web/assets/fieldsettings/src/ConfigOptions.js index 4c45f860..b9819858 100644 --- a/src/web/assets/fieldsettings/src/ConfigOptions.js +++ b/src/web/assets/fieldsettings/src/ConfigOptions.js @@ -9,82 +9,90 @@ import './fieldsettings.css'; export default Garnish.Base.extend({ jsonSchemaUri: null, - language: null, + mode: null, + lastCodeMode: null, $container: null, $jsonContainer: null, $jsContainer: null, + $fileContainer: null, jsonEditor: null, jsEditor: null, defaults: null, - init: function (id, jsonSchemaUri) { + init: function (id, jsonSchemaUri, mode) { this.jsonSchemaUri = jsonSchemaUri; + this.mode = mode; + if (this.mode !== 'file') { + this.lastCodeMode = mode; + } this.$container = $(`#${id}`); + this.$ckeConfigModeInput = $(`#${id}-mode`); this.$jsonContainer = $(`#${id}-json-container`); this.$jsContainer = $(`#${id}-js-container`); + this.$fileContainer = $(`#${id}-file-container`); this.jsonEditor = window.monacoEditorInstances[`${id}-json`]; this.jsEditor = window.monacoEditorInstances[`${id}-js`]; - const $languagePicker = this.$container.children('.btngroup'); - - if (this.$jsonContainer.hasClass('hidden')) { - this.language = 'js'; - } else { - this.language = 'json'; - } + const $modePicker = this.$container.children('.btngroup'); this.defaults = {}; - let lastJsValue = null; + const $containers = this.$jsonContainer + .add(this.$jsContainer) + .add(this.$fileContainer); - new Craft.Listbox($languagePicker, { + new Craft.Listbox($modePicker, { onChange: ($selectedOption) => { - this.language = $selectedOption.data('language'); - switch (this.language) { + this.mode = $selectedOption.data('mode'); + this.$ckeConfigModeInput.val(this.mode); + $containers.addClass('hidden'); + switch (this.mode) { case 'json': - // get the js value - lastJsValue = this.jsEditor.getModel().getValue(); - // check if the js value has any functions in it - if (this.jsContainsFunctions(lastJsValue)) { - // if it does - show the confirmation dialogue - if ( - !confirm( - Craft.t( - 'ckeditor', - 'Your JavaScript config contains functions. If you switch to JSON, they will be lost. Would you like to continue?', - ), - ) - ) { - // if user cancels - go back to the previous option (js) - let listbox = $languagePicker.data('listbox'); - listbox.$options.not('[data-language="json"]').trigger('click'); - break; + // was JS the previously-selected non-file mode? + if (this.lastCodeMode === 'js') { + // get the js value + const js = this.jsEditor.getModel().getValue(); + // check if the js value has any functions in it + if (this.jsContainsFunctions(js)) { + // if it does - show the confirmation dialogue + if ( + !confirm( + Craft.t( + 'ckeditor', + 'Your JavaScript config contains functions. If you switch to JSON, they will be lost. Would you like to continue?', + ), + ) + ) { + // if user cancels - go back to JS + const listbox = $modePicker.data('listbox'); + listbox.$options.filter('[data-mode="js"]').trigger('click'); + break; + } } + + const json = this.js2json(js); + this.jsonEditor.getModel().setValue(json || '{\n \n}'); + this.jsEditor.getModel().setValue(''); } - // if user confirms that they want to proceed, or we don't have functions in the js value, - // go ahead and switch + this.$jsonContainer.removeClass('hidden'); - this.$jsContainer.addClass('hidden'); - const json = this.js2json(lastJsValue); - lastJsValue = null; - this.jsonEditor.getModel().setValue(json || '{\n \n}'); - this.jsEditor.getModel().setValue(''); break; case 'js': - this.$jsonContainer.addClass('hidden'); - this.$jsContainer.removeClass('hidden'); - let js; - // if we have the last remembered js value, it means we're switching back after cancelled confirmation, - // so let's use it - if (lastJsValue !== null) { - js = lastJsValue; - lastJsValue = null; - } else { - js = this.json2js(this.jsonEditor.getModel().getValue()); + if (this.lastCodeMode === 'json') { + const json = this.jsonEditor.getModel().getValue(); + const js = this.json2js(json); + this.jsEditor.getModel().setValue(js || 'return {\n \n}'); + this.jsonEditor.getModel().setValue(''); } - this.jsEditor.getModel().setValue(js || 'return {\n \n}'); - this.jsonEditor.getModel().setValue(''); + this.$jsContainer.removeClass('hidden'); + break; + case 'file': + this.$fileContainer.removeClass('hidden'); break; } + + if (this.mode !== 'file') { + this.lastCodeMode = this.mode; + } }, }); @@ -116,7 +124,7 @@ export default Garnish.Base.extend({ getConfig: function () { let json; - if (this.language === 'json') { + if (this.mode === 'json') { json = Craft.trim(this.jsonEditor.getModel().getValue()) || '{}'; } else { const value = Craft.trim(this.jsEditor.getModel().getValue()); @@ -137,7 +145,7 @@ export default Garnish.Base.extend({ setConfig: function (config) { const json = this.config2json(config); - if (this.language === 'json') { + if (this.mode === 'json') { this.jsonEditor.getModel().setValue(json); } else { const js = this.json2js(json); diff --git a/src/web/assets/fieldsettings/src/CssOptions.js b/src/web/assets/fieldsettings/src/CssOptions.js new file mode 100644 index 00000000..b9428fba --- /dev/null +++ b/src/web/assets/fieldsettings/src/CssOptions.js @@ -0,0 +1,40 @@ +/** + * @link https://craftcms.com/ + * @copyright Copyright (c) Pixel & Tonic, Inc. + * @license GPL-3.0-or-later + */ + +/** global: CKEditor5, Garnish, $ */ +export default Garnish.Base.extend({ + mode: null, + $container: null, + $cssContainer: null, + $fileContainer: null, + + init: function (id, mode) { + this.mode = mode; + this.$container = $(`#${id}`); + this.$ckeConfigModeInput = $(`#${id}-mode`); + this.$cssContainer = $(`#${id}-css-container`); + this.$fileContainer = $(`#${id}-file-container`); + const $modePicker = this.$container.children('.btngroup'); + + const $containers = this.$cssContainer.add(this.$fileContainer); + + new Craft.Listbox($modePicker, { + onChange: ($selectedOption) => { + this.mode = $selectedOption.data('mode'); + this.$ckeConfigModeInput.val(this.mode); + $containers.addClass('hidden'); + switch (this.mode) { + case 'css': + this.$cssContainer.removeClass('hidden'); + break; + case 'file': + this.$fileContainer.removeClass('hidden'); + break; + } + }, + }); + }, +}); diff --git a/src/web/assets/fieldsettings/src/fieldsettings.js b/src/web/assets/fieldsettings/src/fieldsettings.js index a578214e..864b9070 100644 --- a/src/web/assets/fieldsettings/src/fieldsettings.js +++ b/src/web/assets/fieldsettings/src/fieldsettings.js @@ -6,4 +6,5 @@ export {default as ToolbarBuilder} from './ToolbarBuilder.js'; export {default as ConfigOptions} from './ConfigOptions.js'; +export {default as CssOptions} from './CssOptions.js'; export {default as CkeEntryTypeSelectInput} from './CkeEntryTypeSelectInput.js'; From c774f0913b32c8dbabc951fbd8ac5d5e80312343 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 18 Mar 2026 19:47:16 -0700 Subject: [PATCH 2/4] Fixed a couple issues --- src/Field.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Field.php b/src/Field.php index a97f97db..055f7e8c 100644 --- a/src/Field.php +++ b/src/Field.php @@ -1085,13 +1085,13 @@ private function settingsHtml(bool $readOnly): string $jsonSchemaUri = sprintf('https://craft-code-editor.com/%s', $view->namespaceInputId('config-options-json')); - $configMode = match(true) { + $configMode = match (true) { !empty($this->js) => 'js', !empty($this->jsFile) => 'file', default => 'json', }; - $cssMode = match(true) { + $cssMode = match (true) { !empty($this->cssFile) => 'file', default => 'css', }; @@ -1535,7 +1535,7 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat $importCompliantUiLanguage = BaseCkeditorPackageAsset::getImportCompliantLanguage(BaseCkeditorPackageAsset::uiLanguage()); $uiTranslationImport = "import coreTranslations from 'ckeditor5/translations/$importCompliantUiLanguage.js';"; - $configJs = $this->configJs() ?? '{}'; + $configJs = $this->configJs(); $view->registerScriptWithVars(fn( $baseConfigJs, @@ -1712,7 +1712,7 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat ]); } - private function configJs(): ?string + private function configJs(): string { if (isset($this->jsFile) && strtolower(pathinfo($this->jsFile, PATHINFO_EXTENSION)) === 'json') { $this->options = self::jsonFileContents($this->jsFile); From db4f38c60e031f62f488db2b1a3c3632a21e94f2 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Wed, 18 Mar 2026 19:48:42 -0700 Subject: [PATCH 3/4] Release notes --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 356ec3c7..b9eabc7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Release Notes for CKEditor for Craft CMS +## Unreleased + +- It’s now possible to set the “Config Options” setting to a JS or JSON file within `config/ckeditor/`. ([#540](https://github.com/craftcms/ckeditor/pull/540)) +- It’s now possible to set the “Custom Styles” setting to a CSS file within `config/ckeditor/`. ([#540](https://github.com/craftcms/ckeditor/pull/540)) +- The v5 migration now automatically creates JS/JSON/CSS files within `config/ckeditor/` for any Config Options/Custom Styles from CKEditor Configs that were used by two or more fields. ([#540](https://github.com/craftcms/ckeditor/pull/540)) + ## 5.2.1 - 2026-03-18 - Fixed a bug where `@import` statements within custom styles weren’t working. ([#538](https://github.com/craftcms/ckeditor/pull/538)) From 7e61844d2ed9d7e85c31418f007394ad14fedd4f Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 19 Mar 2026 11:03:08 -0700 Subject: [PATCH 4/4] Bug fixes --- src/Field.php | 4 +- src/templates/_field-settings.twig | 24 +++++-- .../fieldsettings/dist/fieldsettings.js | 70 ++++++++++--------- .../assets/fieldsettings/src/ConfigOptions.js | 9 ++- .../assets/fieldsettings/src/CssOptions.js | 9 ++- 5 files changed, 69 insertions(+), 47 deletions(-) diff --git a/src/Field.php b/src/Field.php index 055f7e8c..0a23848f 100644 --- a/src/Field.php +++ b/src/Field.php @@ -694,7 +694,7 @@ public function __construct($config = []) if ($config['json'] === '' || preg_match('/^\{\s*\}$/', $config['json'])) { unset($config['json']); } - unset($config['js'], $config['file']); + unset($config['js'], $config['jsFile']); break; } @@ -714,7 +714,7 @@ public function __construct($config = []) if ($config['css'] === '') { unset($config['css']); } - unset($config['file']); + unset($config['cssFile']); break; } diff --git a/src/templates/_field-settings.twig b/src/templates/_field-settings.twig index 7be4d82f..9b3616bb 100644 --- a/src/templates/_field-settings.twig +++ b/src/templates/_field-settings.twig @@ -421,7 +421,7 @@ text: 'File', }) }} - +
{% tag 'div' with { @@ -479,10 +479,24 @@ {{ importStatements|raw }} function initializeConfig() { - const configOptions = new ConfigOptions("{{ configOptionsId }}", "{{ jsonSchemaUri }}", "{{ configMode }}"); - new ToolbarBuilder("{{ toolbarBuilderId }}", configOptions, [{{ plugins | join(',') }}]); - - new CssOptions("{{ cssOptionsId }}", "{{ cssMode }}"); + const configOptions = new ConfigOptions( + {{ configOptionsId|json_encode|raw }}, + {{ jsonSchemaUri|json_encode|raw }}, + {{ configMode|json_encode|raw }}, + {{ (jsFileOptions is not empty)|json_encode|raw }} + ); + + new ToolbarBuilder( + "{{ toolbarBuilderId }}", + configOptions, + [{{ plugins | join(',') }}] + ); + + new CssOptions( + {{ cssOptionsId|json_encode|raw }}, + {{ cssMode|json_encode|raw }}, + {{ (cssFileOptions is not empty)|json_encode|raw }} + ); (() => { // Register the config options JSON schema diff --git a/src/web/assets/fieldsettings/dist/fieldsettings.js b/src/web/assets/fieldsettings/dist/fieldsettings.js index 9c54b5f5..bc12b19d 100644 --- a/src/web/assets/fieldsettings/dist/fieldsettings.js +++ b/src/web/assets/fieldsettings/dist/fieldsettings.js @@ -35,17 +35,17 @@ const ToolbarBuilder = Garnish.Base.extend({ const h = a.ui.componentFactory; for (const i of h.names()) this.components[i] = h.create(i); - const l = JSON.parse(this.$container.attr("data-available-items")); - for (let i = 0; i < l.length; i++) { - const s = l[i]; + const c = JSON.parse(this.$container.attr("data-available-items")); + for (let i = 0; i < c.length; i++) { + const s = c[i]; if (s.length > 1) { const d = this.value.findIndex( - (c) => s.some((f) => f.button === c) + (l) => s.some((f) => f.button === l) ); if (d !== -1) { - for (let c = 0; c < s.length; c++) - if (this.value[d + c] !== s[c].button) { - l.splice(i, 1, ...s.map((f) => [f])), i += s.length - 1; + for (let l = 0; l < s.length; l++) + if (this.value[d + l] !== s[l].button) { + c.splice(i, 1, ...s.map((f) => [f])), i += s.length - 1; break; } } @@ -100,8 +100,8 @@ const ToolbarBuilder = Garnish.Base.extend({ else { const d = i.data("componentNames"); s = this.renderComponentGroup(d); - for (const c of d) { - const f = l.flat().find(({ button: g }) => g === c); + for (const l of d) { + const f = c.flat().find(({ button: g }) => g === l); f && f.configOption && e.addSetting(f.configOption); } } @@ -113,16 +113,16 @@ const ToolbarBuilder = Garnish.Base.extend({ const s = $(i.data("sourceItem")); if (i.remove(), this.drag.$draggee = i = s, !this.draggingSeparator) for (const d of s.data("componentNames")) { - const c = l.flat().find(({ button: f }) => f === d); - c && c.configOption && e.removeSetting(c.configOption); + const l = c.flat().find(({ button: f }) => f === d); + l && l.configOption && e.removeSetting(l.configOption); } } if (!this.draggingSeparator) { i.removeClass("hidden"); const s = Craft.orientation === "ltr" ? "margin-right" : "margin-left", d = i.css(s); i.css(s, ""); - const c = i.css(s); - i.css(s, d), i.stop().velocity({ [s]: c }, 200, () => { + const l = i.css(s); + i.css(s, d), i.stop().velocity({ [s]: l }, 200, () => { i.css(s, ""); }); } @@ -136,7 +136,7 @@ const ToolbarBuilder = Garnish.Base.extend({ } }); const u = {}; - for (let i of l) { + for (let i of c) { const s = this.renderComponentGroup(i); s && (s.appendTo(this.$sourceContainer), u[i.map((d) => d.button).join(",")] = s[0], this.value.includes(i[0].button) && s.addClass("hidden")); } @@ -145,18 +145,18 @@ const ToolbarBuilder = Garnish.Base.extend({ )[0], this.$items = $(); for (let i = 0; i < this.value.length; i++) { const s = this.value[i]; - let d, c; + let d, l; if (s === "|") - d = this.renderSeparator().appendTo(this.$targetContainer), c = "|"; + d = this.renderSeparator().appendTo(this.$targetContainer), l = "|"; else { - const f = l.find( + const f = c.find( (g) => g.some((p) => p.button === s) ); if (!f || (d = this.renderComponentGroup(f), !d)) continue; - d.appendTo(this.$targetContainer), c = f.map((g) => g.button).join(","), i += f.length - 1; + d.appendTo(this.$targetContainer), l = f.map((g) => g.button).join(","), i += f.length - 1; } - d.data("sourceItem", u[c]), this.$items = this.$items.add(d); + d.data("sourceItem", u[l]), this.$items = this.$items.add(d); } }).catch(console.error); }, @@ -175,8 +175,8 @@ const ToolbarBuilder = Garnish.Base.extend({ let a; try { a = this.renderComponent(r); - } catch (l) { - console.warn(l); + } catch (c) { + console.warn(c); continue; } e.push(a); @@ -247,20 +247,21 @@ const ConfigOptions = Garnish.Base.extend({ mode: null, lastCodeMode: null, $container: null, + $modeInput: null, $jsonContainer: null, $jsContainer: null, $fileContainer: null, jsonEditor: null, jsEditor: null, defaults: null, - init: function(id, jsonSchemaUri, mode) { - this.jsonSchemaUri = jsonSchemaUri, this.mode = mode, this.mode !== "file" && (this.lastCodeMode = mode), this.$container = $(`#${id}`), this.$ckeConfigModeInput = $(`#${id}-mode`), this.$jsonContainer = $(`#${id}-json-container`), this.$jsContainer = $(`#${id}-js-container`), this.$fileContainer = $(`#${id}-file-container`), this.jsonEditor = window.monacoEditorInstances[`${id}-json`], this.jsEditor = window.monacoEditorInstances[`${id}-js`]; + init: function(id, jsonSchemaUri, mode, hasFiles) { + this.jsonSchemaUri = jsonSchemaUri, this.mode = mode, this.mode !== "file" && (this.lastCodeMode = mode), this.$container = $(`#${id}`), this.$modeInput = $(`#${id}-mode`), this.$jsonContainer = $(`#${id}-json-container`), this.$jsContainer = $(`#${id}-js-container`), this.$fileContainer = $(`#${id}-file-container`), this.jsonEditor = window.monacoEditorInstances[`${id}-json`], this.jsEditor = window.monacoEditorInstances[`${id}-js`]; const $modePicker = this.$container.children(".btngroup"); this.defaults = {}; const $containers = this.$jsonContainer.add(this.$jsContainer).add(this.$fileContainer); new Craft.Listbox($modePicker, { onChange: (t) => { - switch (this.mode = t.data("mode"), this.$ckeConfigModeInput.val(this.mode), $containers.addClass("hidden"), this.mode) { + switch (this.mode = t.data("mode"), (this.mode !== "file" || hasFiles) && this.$modeInput.val(this.mode), $containers.addClass("hidden"), this.mode) { case "json": if (this.lastCodeMode === "js") { const e = this.jsEditor.getModel().getValue(); @@ -450,14 +451,15 @@ const ConfigOptions = Garnish.Base.extend({ const CssOptions = Garnish.Base.extend({ mode: null, $container: null, + $modeInput: null, $cssContainer: null, $fileContainer: null, - init: function(t, e) { - this.mode = e, this.$container = $(`#${t}`), this.$ckeConfigModeInput = $(`#${t}-mode`), this.$cssContainer = $(`#${t}-css-container`), this.$fileContainer = $(`#${t}-file-container`); - const n = this.$container.children(".btngroup"), o = this.$cssContainer.add(this.$fileContainer); - new Craft.Listbox(n, { - onChange: (r) => { - switch (this.mode = r.data("mode"), this.$ckeConfigModeInput.val(this.mode), o.addClass("hidden"), this.mode) { + init: function(t, e, n) { + this.mode = e, this.$container = $(`#${t}`), this.$modeInput = $(`#${t}-mode`), this.$cssContainer = $(`#${t}-css-container`), this.$fileContainer = $(`#${t}-file-container`); + const o = this.$container.children(".btngroup"), r = this.$cssContainer.add(this.$fileContainer); + new Craft.Listbox(o, { + onChange: (a) => { + switch (this.mode = a.data("mode"), (this.mode !== "file" || n) && this.$modeInput.val(this.mode), r.addClass("hidden"), this.mode) { case "css": this.$cssContainer.removeClass("hidden"); break; @@ -492,8 +494,8 @@ const CssOptions = Garnish.Base.extend({ e ); o.on("show", () => { - let h = o.$trigger.parents(".chip"), l = this.getConfigFromComponent(h); - o.toggleItem(r, !l.expanded), o.toggleItem(a, l.expanded); + let h = o.$trigger.parents(".chip"), c = this.getConfigFromComponent(h); + o.toggleItem(r, !c.expanded), o.toggleItem(a, c.expanded); }), this.applyIndicators(t, this.getConfig(e)), this.base(t); }, async applyConfigChange(t, e, n) { @@ -516,12 +518,12 @@ const CssOptions = Garnish.Base.extend({ throw Craft.cp.displayError((i = (u = s == null ? void 0 : s.response) == null ? void 0 : u.data) == null ? void 0 : i.message), s; } let o = t.find(".indicators"); - const r = this.getInput(t), a = $(n.chip).find(".indicators"), h = this.getInput($(n.chip)), l = this.getConfig(h); + const r = this.getInput(t), a = $(n.chip).find(".indicators"), h = this.getInput($(n.chip)), c = this.getConfig(h); if (o.length == 0) { const s = t.find(".chip-label"); o = $('
').appendTo(s); } - o.replaceWith(a), this.updateConfig(r, l); + o.replaceWith(a), this.updateConfig(r, c); }, updateConfig: function(t, e) { t.val(JSON.stringify(e)); diff --git a/src/web/assets/fieldsettings/src/ConfigOptions.js b/src/web/assets/fieldsettings/src/ConfigOptions.js index b9819858..0b440ae0 100644 --- a/src/web/assets/fieldsettings/src/ConfigOptions.js +++ b/src/web/assets/fieldsettings/src/ConfigOptions.js @@ -12,6 +12,7 @@ export default Garnish.Base.extend({ mode: null, lastCodeMode: null, $container: null, + $modeInput: null, $jsonContainer: null, $jsContainer: null, $fileContainer: null, @@ -19,14 +20,14 @@ export default Garnish.Base.extend({ jsEditor: null, defaults: null, - init: function (id, jsonSchemaUri, mode) { + init: function (id, jsonSchemaUri, mode, hasFiles) { this.jsonSchemaUri = jsonSchemaUri; this.mode = mode; if (this.mode !== 'file') { this.lastCodeMode = mode; } this.$container = $(`#${id}`); - this.$ckeConfigModeInput = $(`#${id}-mode`); + this.$modeInput = $(`#${id}-mode`); this.$jsonContainer = $(`#${id}-json-container`); this.$jsContainer = $(`#${id}-js-container`); this.$fileContainer = $(`#${id}-file-container`); @@ -43,7 +44,9 @@ export default Garnish.Base.extend({ new Craft.Listbox($modePicker, { onChange: ($selectedOption) => { this.mode = $selectedOption.data('mode'); - this.$ckeConfigModeInput.val(this.mode); + if (this.mode !== 'file' || hasFiles) { + this.$modeInput.val(this.mode); + } $containers.addClass('hidden'); switch (this.mode) { case 'json': diff --git a/src/web/assets/fieldsettings/src/CssOptions.js b/src/web/assets/fieldsettings/src/CssOptions.js index b9428fba..e310ac3b 100644 --- a/src/web/assets/fieldsettings/src/CssOptions.js +++ b/src/web/assets/fieldsettings/src/CssOptions.js @@ -8,13 +8,14 @@ export default Garnish.Base.extend({ mode: null, $container: null, + $modeInput: null, $cssContainer: null, $fileContainer: null, - init: function (id, mode) { + init: function (id, mode, hasFiles) { this.mode = mode; this.$container = $(`#${id}`); - this.$ckeConfigModeInput = $(`#${id}-mode`); + this.$modeInput = $(`#${id}-mode`); this.$cssContainer = $(`#${id}-css-container`); this.$fileContainer = $(`#${id}-file-container`); const $modePicker = this.$container.children('.btngroup'); @@ -24,7 +25,9 @@ export default Garnish.Base.extend({ new Craft.Listbox($modePicker, { onChange: ($selectedOption) => { this.mode = $selectedOption.data('mode'); - this.$ckeConfigModeInput.val(this.mode); + if (this.mode !== 'file' || hasFiles) { + this.$modeInput.val(this.mode); + } $containers.addClass('hidden'); switch (this.mode) { case 'css':