diff --git a/core/modules/ckeditor5/ckeditor5.libraries.yml b/core/modules/ckeditor5/ckeditor5.libraries.yml index a4f082c2c5ca946a949a5fc6eec63a84a08a3f9c..d25a96a0ca19b7c62d125e5efa9905795bb724f5 100644 --- a/core/modules/ckeditor5/ckeditor5.libraries.yml +++ b/core/modules/ckeditor5/ckeditor5.libraries.yml @@ -21,6 +21,7 @@ drupal.ckeditor5: css/quickedit.css: { } dependencies: - core/jquery + - core/once - core/drupal - core/drupal.debounce - core/ckeditor5.editorClassic diff --git a/core/modules/ckeditor5/js/ckeditor5.es6.js b/core/modules/ckeditor5/js/ckeditor5.es6.js index a200557fe74d52bca9a682b5f28691247d6e3bb7..e41d911c1f012b6bee06261c546ee516e120dcfc 100644 --- a/core/modules/ckeditor5/js/ckeditor5.es6.js +++ b/core/modules/ckeditor5/js/ckeditor5.es6.js @@ -3,7 +3,7 @@ * CKEditor 5 implementation of {@link Drupal.editors} API. */ /* global CKEditor5 */ -((Drupal, debounce, CKEditor5, $) => { +((Drupal, debounce, CKEditor5, $, once) => { /** * The CKEDITOR instances. * @@ -165,55 +165,131 @@ }); } + /** + * 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 analysed 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. */ - const offCanvasCss = (element) => { - element.parentNode.setAttribute('data-drupal-ck-style-fence', true); - + 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 (!document.querySelector('#ckeditor5-off-canvas-reset')) { - const prefix = `#drupal-off-canvas [data-drupal-ck-style-fence]`; - let existingCss = ''; - - // Find every existing style that doesn't come from off-canvas resets and - // copy them to new styles with a prefix targeting CKEditor inside an - // off-canvas dialog. - [...document.styleSheets].forEach((sheet) => { - if ( - !sheet.href || - (sheet.href && sheet.href.indexOf('off-canvas') === -1) - ) { - // This is wrapped in a try/catch as Chromium browsers will fail if - // the stylesheet was provided via a CORS request. - // @see https://bugs.chromium.org/p/chromium/issues/detail?id=775525 - try { - const rules = sheet.cssRules; - [...rules].forEach((rule) => { - let { cssText } = rule; - const selector = rule.cssText.split('{')[0]; - - // Prefix all selectors added after a comma. - cssText = cssText.replace( - selector, - selector.replace(/,/g, `, ${prefix}`), - ); - - // When adding to existingCss, prefix the first selector as well. - existingCss += `${prefix} ${cssText}`; - }); - } catch (e) { - // eslint-disable-next-line no-console - console.warn( - `Stylesheet ${sheet.href} not included in CKEditor reset due to the browser's CORS policy.`, - ); - } - } - }); + 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 [${fenceName}]`; // Additional styles that need to be explicity added in addition to the // prefixed versions of existing css in `existingCss`. const addedCss = [ @@ -223,7 +299,6 @@ `${prefix} .ck.ck-content ol li {list-style-type: decimal}`, `${prefix} .ck[contenteditable], ${prefix} .ck[contenteditable] * {-webkit-user-modify: read-write;-moz-user-modify: read-write;}`, ]; - // Styles to ensure block elements are displayed as such inside // off-canvas dialogs. These are all element types that are styled with // ` all: initial;` in the off-canvas reset that should default to being @@ -268,15 +343,15 @@ .join(', \n'); const blockCss = `${blockSelectors} { display: block; }`; - const prefixedCss = [...addedCss, existingCss, blockCss].join('\n'); + const prefixedCss = [...addedCss, blockCss].join('\n'); // Create a new style tag with the prefixed styles added above. - const offCanvasCss = document.createElement('style'); - offCanvasCss.innerHTML = prefixedCss; - offCanvasCss.setAttribute('id', 'ckeditor5-off-canvas-reset'); - document.body.appendChild(offCanvasCss); + const offCanvasCssStyle = document.createElement('style'); + offCanvasCssStyle.textContent = prefixedCss; + offCanvasCssStyle.setAttribute('id', 'ckeditor5-off-canvas-reset'); + document.body.appendChild(offCanvasCssStyle); } - }; + } /** * @namespace @@ -581,4 +656,4 @@ Drupal.ckeditor5.saveCallback = null; } }); -})(Drupal, Drupal.debounce, CKEditor5, jQuery); +})(Drupal, Drupal.debounce, CKEditor5, jQuery, once); diff --git a/core/modules/ckeditor5/js/ckeditor5.js b/core/modules/ckeditor5/js/ckeditor5.js index 59e014ae78b8ac367a285026470dce97c1a3e5b3..7e24a25a4dfbcf6d04bd56618148c520beca5e32 100644 --- a/core/modules/ckeditor5/js/ckeditor5.js +++ b/core/modules/ckeditor5/js/ckeditor5.js @@ -5,7 +5,7 @@ * @preserve **/ -((Drupal, debounce, CKEditor5, $) => { +((Drupal, debounce, CKEditor5, $, once) => { Drupal.CKEditor5Instances = new Map(); const callbacks = new Map(); const required = new Set(); @@ -98,39 +98,60 @@ }); } - const offCanvasCss = element => { - element.parentNode.setAttribute('data-drupal-ck-style-fence', true); - - if (!document.querySelector('#ckeditor5-off-canvas-reset')) { - const prefix = `#drupal-off-canvas [data-drupal-ck-style-fence]`; - let existingCss = ''; - [...document.styleSheets].forEach(sheet => { - if (!sheet.href || sheet.href && sheet.href.indexOf('off-canvas') === -1) { - try { - const rules = sheet.cssRules; - [...rules].forEach(rule => { - let { - cssText - } = rule; - const selector = rule.cssText.split('{')[0]; - cssText = cssText.replace(selector, selector.replace(/,/g, `, ${prefix}`)); - existingCss += `${prefix} ${cssText}`; - }); - } catch (e) { - console.warn(`Stylesheet ${sheet.href} not included in CKEditor reset due to the browser's CORS policy.`); - } + function processRules(rulesGroup) { + try { + [...rulesGroup.cssRules].forEach(ckeditor5SelectorProcessing); + } catch (e) { + console.warn(`Stylesheet ${rulesGroup.href} not included in CKEditor reset due to the browser's CORS policy.`); + } + } + + function ckeditor5SelectorProcessing(rule) { + 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 => { + if (selector.includes(offCanvasId)) { + return `${selector.trim()}:not(${styleFence} *)`; } - }); + + if (selector.includes(CKEditorClass)) { + return [selector.trim(), selector.trim().replace(CKEditorClass, `${offCanvasId} ${styleFence} ${CKEditorClass}`)]; + } + + return selector; + }).flat().join(', '); + } + } + + 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, ''); + + if (once('ckeditor5-off-canvas-reset', 'body').length) { + [...document.styleSheets].forEach(processRules); + const prefix = `#drupal-off-canvas [${fenceName}]`; const addedCss = [`${prefix} .ck.ck-content {display:block;min-height:5rem;}`, `${prefix} .ck.ck-content * {display:initial;background:initial;color:initial;padding:initial;}`, `${prefix} .ck.ck-content li {display:list-item}`, `${prefix} .ck.ck-content ol li {list-style-type: decimal}`, `${prefix} .ck[contenteditable], ${prefix} .ck[contenteditable] * {-webkit-user-modify: read-write;-moz-user-modify: read-write;}`]; const blockSelectors = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ol', 'ul', 'address', 'article', 'aside', 'blockquote', 'body', 'dd', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 'hr', 'html', 'legend', 'main', 'menu', 'pre', 'section', 'xmp'].map(blockElement => `${prefix} .ck.ck-content ${blockElement}`).join(', \n'); const blockCss = `${blockSelectors} { display: block; }`; - const prefixedCss = [...addedCss, existingCss, blockCss].join('\n'); - const offCanvasCss = document.createElement('style'); - offCanvasCss.innerHTML = prefixedCss; - offCanvasCss.setAttribute('id', 'ckeditor5-off-canvas-reset'); - document.body.appendChild(offCanvasCss); + const prefixedCss = [...addedCss, blockCss].join('\n'); + const offCanvasCssStyle = document.createElement('style'); + offCanvasCssStyle.textContent = prefixedCss; + offCanvasCssStyle.setAttribute('id', 'ckeditor5-off-canvas-reset'); + document.body.appendChild(offCanvasCssStyle); } - }; + } Drupal.editors.ckeditor5 = { attach(element, format) { @@ -334,4 +355,4 @@ Drupal.ckeditor5.saveCallback = null; } }); -})(Drupal, Drupal.debounce, CKEditor5, jQuery); \ No newline at end of file +})(Drupal, Drupal.debounce, CKEditor5, jQuery, once); \ No newline at end of file