Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

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,680 @@
/**
* @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: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,10 @@
// cspell:ignore drupalhtmlengine mediaimagetextalternativeediting
// cspell:ignore mediaimagetextalternativeui
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,42 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore imagealternativetext imagetextalternative */
/* cspell:ignore imagetextalternativeediting drupalimagealternativetextediting */
/* cspell:ignore 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,170 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore imagealternativetext imagetextalternative */
/* cspell:ignore imagetextalternativecommand drupalimagealternativetextediting */
/* cspell:ignore drupalimagetextalternativecommand textalternativemissingview */
/**
* @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,331 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore imagealternativetext imagetextalternative */
/* cspell:ignore imagetextalternativeui contextualballoon componentfactory */
/* cspell:ignore drupalimagealternativetextui imagealternativetextformview */
/* cspell:ignore missingalternativetextview */
/**
* @module drupalImage/imagealternativetext/drupalimagealternativetextui
*/
import { Plugin } from 'ckeditor5/src/core';
import { IconLowVision } from '@ckeditor/ckeditor5-icons';
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: IconLowVision,
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 { IconCheck, IconCancel } from '@ckeditor/ckeditor5-icons';
/**
* 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'),
IconCheck,
'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'),
IconCancel,
'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,150 @@
/* 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?.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,136 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalelementstyle drupalelementstylecommand */
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);
}
});
}
}

View File

@ -0,0 +1,352 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalelementstyle drupalelementstylecommand */
/* cspell:ignore drupalelementstyleediting */
import { Plugin } from 'ckeditor5/src/core';
import * as icons from '@ckeditor/ckeditor5-icons';
import { first } from 'ckeditor5/src/utils';
import DrupalElementStyleCommand from './drupalelementstylecommand';
import { groupNameToModelAttributeKey } from '../utils';
/**
* @module drupalMedia/drupalelementstyle/drupalelementstyleediting
*/
/**
* Gets style definition by name.
*
* @param {string} name
* The name of the style definition.
* @param {object} styles
* The styles to search from.
* @return {Drupal.CKEditor5~DrupalElementStyle}
*/
function getStyleDefinitionByName(name, styles) {
// eslint-disable-next-line no-restricted-syntax
for (const style of styles) {
if (style.name === name) {
return style;
}
}
}
/**
* Returns a model-to-view converted for Drupal Element styles.
*
* This model to view converter supports downcasting model to either a CSS class
* or attribute.
*
* Note that only one style can be applied to a single model element.
*
* @param {object} styles
* Existing styles.
*/
function modelToViewStyleAttribute(styles) {
return (evt, data, conversionApi) => {
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
// Check if there is a style associated with given value.
const newStyle = getStyleDefinitionByName(data.attributeNewValue, styles);
const oldStyle = getStyleDefinitionByName(data.attributeOldValue, styles);
const viewElement = conversionApi.mapper.toViewElement(data.item);
const viewWriter = conversionApi.writer;
if (oldStyle) {
if (oldStyle.attributeName === 'class') {
viewWriter.removeClass(oldStyle.attributeValue, viewElement);
} else {
viewWriter.removeAttribute(oldStyle.attributeName, viewElement);
}
}
if (newStyle) {
if (newStyle.attributeName === 'class') {
viewWriter.addClass(newStyle.attributeValue, viewElement);
} else if (!newStyle.isDefault) {
// We only reach this condition if the style is not the default value.
// In those instances, there is no need to downcast as the default value
// is set automatically when necessary.
viewWriter.setAttribute(
newStyle.attributeName,
newStyle.attributeValue,
viewElement,
);
}
}
};
}
/**
* Returns a view-to-model converter for Drupal Element styles.
*
* This view to model converted supports styles that are configured to use
* either CSS class or an attribute.
*
* Note that more than one style can be applied to each modelElement.
*/
function viewToModelStyleAttribute(styles, modelAttribute) {
// Convert only nondefault styles.
const nonDefaultStyles = styles.filter((style) => !style.isDefault);
return (evt, data, conversionApi) => {
if (!data.modelRange) {
return;
}
const viewElement = data.viewItem;
const modelElement = first(data.modelRange.getItems());
// Run this converter only if a model element has been found from the model.
if (!modelElement) {
return;
}
// Stop conversion early if modelAttribute represents an attribute that isn't allowed
// for the element.
if (!conversionApi.schema.checkAttribute(modelElement, modelAttribute)) {
return;
}
// Convert styles with CSS classes one by one.
// eslint-disable-next-line no-restricted-syntax
for (const style of nonDefaultStyles) {
// Try to consume class corresponding with the style.
if (style.attributeName === 'class') {
if (
conversionApi.consumable.consume(viewElement, {
classes: style.attributeValue,
})
) {
// And convert this style to model attribute.
conversionApi.writer.setAttribute(
modelAttribute,
style.name,
modelElement,
);
}
} else if (
conversionApi.consumable.consume(viewElement, {
attributes: [style.attributeName],
})
) {
// eslint-disable-next-line no-restricted-syntax
for (const style of nonDefaultStyles) {
if (
style.attributeValue ===
viewElement.getAttribute(style.attributeName)
) {
conversionApi.writer.setAttribute(
modelAttribute,
style.name,
modelElement,
);
}
}
}
}
};
}
/**
* The Drupal Element Style editing plugin.
*
* Additional Drupal Element Styles can be defined with `drupalElementStyles`
* configuration key.
*
* Additional Drupal Element Styles can support multiple axes (e.g. media
* alignment and media view modes) by adding the new group under
* drupalElementStyles.
*
* @example
* config:
* drupalElementStyles:
* side:
* - name: 'side'
* icon: 'IconObjectRight'
* title: 'Side image'
* attributeName: 'class'
* attributeValue: 'image-side'
* modelElements: ['drupalMedia']
* align:
* - name: 'right'
* title: 'Right aligned media'
* icon: 'IconObjectInlineRight'
* attributeName: 'data-align'
* modelElements: [ 'drupalMedia' ]
* - name: 'left'
* title: 'Left aligned media'
* icon: 'IconObjectInlineLeft'
* attributeName: 'data-align'
* attributeValue: 'left'
* modelElements: [ 'drupalMedia' ]
* viewMode:
* - name: 'full view mode'
* title: 'Full view mode'
* attributeName: 'data-view-mode'
* attributeValue: 'full'
* modelElements: [ 'drupalMedia' ]
* - name: 'compact view mode'
* title: 'Compact view mode'
* attributeName: 'data-view-mode'
* attributeValue: 'compact'
* modelElements: [ 'drupalMedia' ]
*
* @see Drupal.CKEditor5~DrupalElementStyleDefinition
*
* @extends module:core/plugin~Plugin
*
* @private
*/
export default class DrupalElementStyleEditing extends Plugin {
/**
* @inheritdoc
*/
init() {
const { editor } = this;
const stylesConfig = editor.config.get('drupalElementStyles');
this.normalizedStyles = {};
/**
* The Drupal Element Style definitions.
*
* @typedef {Object} Drupal.CKEditor5~DrupalElementStyleDefinition
* Object that contains an array of DrupalElementStyle objects for each
* group.
*
* @prop {string} name
* The name of the style used for identifying the button.
* @prop {string} title
* The title of the style displayed in the UI.
* @prop {string} attributeName
* The name of the attribute in view.
* @prop {string} attributeValue
* The value of the attribute in view.
* @prop {string[]} modelElements
* A list of model elements that the style can be attached to.
* @prop {string} [icon]
* An icon for the style button. This needs to either refer to an icon in
* the CKEditor 5 core icons, or this can be the XML content of the icon.
*
* @type {Drupal.CKEditor5~DrupalElementStyleDefinition}
*/
Object.keys(stylesConfig).forEach((group) => {
this.normalizedStyles[group] = stylesConfig[group] // array of styles
.map((style) => {
// Allow defining style icon as a string that is referring to the
// CKEditor 5 default icons.
if (typeof style.icon === 'string') {
if (icons[style.icon]) {
style.icon = icons[style.icon];
}
}
if (style.name) {
// Make sure names are all strings.
style.name = `${style.name}`;
}
return style;
})
.filter((style) => {
if (
!style.isDefault &&
(!style.attributeName || !style.attributeValue)
) {
console.warn(
`${style.attributeValue} drupalElementStyles options must include attributeName and attributeValue.`,
);
return false;
}
if (!style.modelElements || !Array.isArray(style.modelElements)) {
console.warn(
'drupalElementStyles options must include an array of supported modelElements.',
);
return false;
}
if (!style.name) {
console.warn('drupalElementStyles options must include a name.');
return false;
}
return true;
});
});
this._setupConversion();
editor.commands.add(
'drupalElementStyle',
new DrupalElementStyleCommand(editor, this.normalizedStyles),
);
}
/**
* Sets up conversion for Drupal Element Styles.
*
* @see modelToViewStyleAttribute()
* @see viewToModelStyleAttribute()
*
* @private
*/
_setupConversion() {
const { editor } = this;
const { schema } = editor.model;
const groupNamesArr = Object.keys(this.normalizedStyles);
groupNamesArr.forEach((group) => {
const modelAttribute = groupNameToModelAttributeKey(group);
const modelToViewConverter = modelToViewStyleAttribute(
this.normalizedStyles[group],
);
const viewToModelConverter = viewToModelStyleAttribute(
this.normalizedStyles[group],
modelAttribute,
);
editor.editing.downcastDispatcher.on(
`attribute:${modelAttribute}`,
modelToViewConverter,
);
editor.data.downcastDispatcher.on(
`attribute:${modelAttribute}`,
modelToViewConverter,
);
// Allow drupalElementStyle model attributes on all model elements that
// have associated styles.
const modelElements = [
...new Set(
this.normalizedStyles[group]
.map((style) => {
return style.modelElements;
})
.flat(),
),
];
modelElements.forEach((modelElement) => {
schema.extend(modelElement, {
allowAttributes: modelAttribute,
});
});
// View to model converter that runs on all elements.
editor.data.upcastDispatcher.on(
'element',
viewToModelConverter,
// This needs to be set as low priority to ensure this runs always after
// the element has been converted to a model element.
{ priority: 'low' },
);
});
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalElementStyleEditing';
}
}

View File

@ -0,0 +1,558 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore buttonview componentfactory drupalelementstyle */
/* cspell:ignore drupalelementstylecommand drupalelementstyleui */
/* cspell:ignore drupalelementstyleediting imagestyle splitbutton */
import { Plugin } from 'ckeditor5/src/core';
import { Collection, toMap } from 'ckeditor5/src/utils';
import utils from '@ckeditor/ckeditor5-image/src/imagestyle/utils';
import {
addToolbarToDropdown,
addListToDropdown,
ButtonView,
createDropdown,
DropdownButtonView,
SplitButtonView,
ViewModel,
} from 'ckeditor5/src/ui';
import DrupalElementStyleEditing from './drupalelementstyleediting';
import { isObject } from '../utils';
import { getClosestElementWithElementStyleAttribute } from './utils';
/**
* @module drupalMedia/drupalelementstyle/drupalelementstyleui
*/
/**
* Returns the first argument it receives.
*
* @param {*} value
* Any value to be returned by this function.
* @return {*}
* Any value passed as the first argument.
*/
const identity = (value) => {
return value;
};
/**
* Gets the dropdown title.
*
* @param {string} dropdownTitle
* The dropdown title.
* @param {string} buttonTitle
* The button title.
* @return {string}
* The generated dropdown title.
*/
const getDropdownButtonTitle = (dropdownTitle, buttonTitle) => {
return (dropdownTitle ? `${dropdownTitle}: ` : '') + buttonTitle;
};
/**
* Gets the UI Component name.
*
* This is used for getting unique component names for registering the UI
* components in the component factory.
*
* @param {string} name
* The name of the style
* @param {string} group
* The group of the style.
* @return {string}
* The UI component name.
*
* @see module:ui/componentfactory~ComponentFactory
*/
function getUIComponentName(name, group) {
return `drupalElementStyle:${group}:${name}`;
}
/**
* The Drupal Element Style UI plugin.
*
* @extends module:core/plugin~Plugin
*
* @private
*/
export default class DrupalElementStyleUi extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [DrupalElementStyleEditing];
}
/**
* @inheritdoc
*/
init() {
const { plugins } = this.editor;
const toolbarConfig = this.editor.config.get('drupalMedia.toolbar') || [];
const definedStyles = plugins.get(
'DrupalElementStyleEditing',
).normalizedStyles;
Object.keys(definedStyles).forEach((group) => {
definedStyles[group].forEach((style) => {
this._createButton(style, group, definedStyles[group]);
});
});
/**
* A Drupal Element Style dropdown definition.
* One dropdown definition can only contain items from one group.
*
* List dropdown display configuration.
* @example
* config:
* drupalMedia:
* toolbar:
* - name: 'drupalMedia:viewMode'
* display: 'listDropdown'
* items:
* - 'drupalElementStyle:viewMode:default'
* - 'drupalElementStyle:viewMode:full'
* - 'drupalElementStyle:viewMode:media_library'
* - 'drupalElementStyle:viewMode:compact'
* defaultItem: 'drupalElementStyle:viewMode:default'
*
* Split button dropdown display configuration.
* @example
* config:
* drupalMedia:
* toolbar:
* - name: 'drupalMedia:side'
* display: 'splitButton'
* items:
* - 'drupalElementStyle:side:right'
* - 'drupalElementStyle:side:left'
* defaultItem: 'drupalElementStyle:side:right'
*
* Toolbar buttons configuration (non-dropdown).
* @example
* config:
* drupalMedia:
* toolbar:
* - 'drupalElementStyle:align:breakText'
* - 'drupalElementStyle:align:left'
* - 'drupalElementStyle:align:center'
* - 'drupalElementStyle:align:right'
*
* @typedef {Object} Drupal.CKEditor5~drupalElementStyleDropdownDefinition
*
* These properties are needed for a list or split button dropdown
* configuration. Buttons directly on the toolbar without a dropdown can be
* configured like in the align example above.
* @prop {string} name
* The name of the dropdown used for identifying the dropdown, either as a
* list or icons.
* @prop {string} display
* The type of the dropdown used. Available options are `listDropdown` and
* `splitButton`.
* @prop {string[]} items
* The items displayed in the dropdown. These must be styles defined in
* `drupalElementStyles`.
* @prop {string} defaultItem
* The default item of the dropdown. This must be a style defined in
* `drupalElementStyles`.
* @prop {string} [title]
* The title of the dropdown.
*
* @see module:drupalMedia/drupalelementstyle/drupalelementstyleediting:DrupalElementStyleEditing
*/
const definedDropdowns = toolbarConfig.filter(isObject).filter((obj) => {
const items = [];
if (!obj.display) {
console.warn(
'dropdown configuration must include a display key specifying either listDropdown or splitButton.',
);
return false;
}
if (!obj.items.includes(obj.defaultItem)) {
console.warn(
'defaultItem must be part of items in the dropdown configuration.',
);
}
// eslint-disable-next-line no-restricted-syntax
for (const item of obj.items) {
const groupName = item.split(':')[1];
items.push(groupName);
}
if (!items.every((i) => i === items[0])) {
console.warn(
'dropdown configuration should only contain buttons from one group.',
);
return false;
}
return true;
});
definedDropdowns.forEach((dropdownConfig) => {
// Only create dropdowns if there are 2 or more items.
if (dropdownConfig.items.length >= 2) {
const groupName = dropdownConfig.name.split(':')[1];
switch (dropdownConfig.display) {
case 'splitButton':
this._createDropdown(dropdownConfig, definedStyles[groupName]);
break;
case 'listDropdown':
this._createListDropdown(dropdownConfig, definedStyles[groupName]);
break;
default:
break;
}
}
});
}
/**
* Updates the visibility of options depending on the selection's media type.
*
* @param {Drupal.CKEditor5~DrupalElementStyleDefinition[]} definedStyles
* A list of defined styles of one group.
* @param {Drupal.CKEditor5~DrupalElementStyleDefinition} style
* The style to check be checked against the media type's specific styles.
* @param {module:ui/dropdown/utils~ListDropdownItemDefinition|module:ui/button/buttonview} option
* Dropdown item definition or ButtonView
* @param {string} group
* Name of group of the defined styles.
*/
updateOptionVisibility(definedStyles, style, option, group) {
const { selection } = this.editor.model.document;
// Convert DrupalElementStyle[] into an object.
const definedStylesObject = {};
definedStylesObject[group] = definedStyles;
const modelElement = selection
? selection.getSelectedElement()
: getClosestElementWithElementStyleAttribute(
selection,
this.editor.model.schema,
definedStylesObject,
);
const filteredDefinedStyles = definedStyles.filter(function (item) {
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of toMap(item.modelAttributes)) {
if (modelElement && modelElement.hasAttribute(key)) {
return value.includes(modelElement.getAttribute(key));
}
}
return true;
});
// List dropdown case.
// Classes are set on the model of the dropdown item definition for list
// dropdowns.
if (option.hasOwnProperty('model')) {
if (!filteredDefinedStyles.includes(style)) {
// Hide the style option if it is not available for the media type that
// the modelElement is.
option.model.set({ class: 'ck-hidden' });
} else {
// Un-hide the style option here after changing selection to a media
// type that should have the button visible.
option.model.set({ class: '' });
}
// Split button case and non-dropdown toolbar button case.
// Classes are set on the ButtonView.
} else if (!filteredDefinedStyles.includes(style)) {
option.set({ class: 'ck-hidden' });
} else {
option.set({ class: '' });
}
}
/**
* Creates a dropdown and stores it in the component factory.
*
* @param {Drupal.CKEditor5~drupalElementStyleDropdownDefinition} dropdownConfig
* The dropdown configuration.
* @param {Drupal.CKEditor5~DrupalElementStyle[]} definedStyles
* A list of defined styles of one group.
*
* @see module:ui/componentfactory~ComponentFactory
*
* @private
*/
_createDropdown(dropdownConfig, definedStyles) {
const factory = this.editor.ui.componentFactory;
factory.add(dropdownConfig.name, (locale) => {
let defaultButton;
const { defaultItem, items, title } = dropdownConfig;
const buttonViews = items
.filter((itemName) => {
const groupName = itemName.split(':')[1];
return definedStyles.find(
({ name }) => getUIComponentName(name, groupName) === itemName,
);
})
.map((buttonName) => {
const button = factory.create(buttonName);
if (buttonName === defaultItem) {
defaultButton = button;
}
return button;
});
if (items.length !== buttonViews.length) {
utils.warnInvalidStyle({ dropdown: dropdownConfig });
}
const dropdownView = createDropdown(locale, SplitButtonView);
const splitButtonView = dropdownView.buttonView;
addToolbarToDropdown(dropdownView, buttonViews);
splitButtonView.set({
label: getDropdownButtonTitle(title, defaultButton.label),
class: null,
tooltip: true,
});
// If style is selected, show the currently selected style as the default
// button of the split button.
splitButtonView.bind('icon').toMany(buttonViews, 'isOn', (...areOn) => {
const index = areOn.findIndex(identity);
return index < 0 ? defaultButton.icon : buttonViews[index].icon;
});
// If style is selected, use the label of the selected style as the
// default label of the split button.
splitButtonView.bind('label').toMany(buttonViews, 'isOn', (...areOn) => {
const index = areOn.findIndex(identity);
return getDropdownButtonTitle(
title,
index < 0 ? defaultButton.label : buttonViews[index].label,
);
});
// If one of the style is selected, render the split button as selected.
splitButtonView
.bind('isOn')
.toMany(buttonViews, 'isOn', (...areOn) => areOn.some(identity));
// If one of the styles is selected, add a CSS class to the split button
// which modifies the styles to indicate that the splitbutton default
// option is currently selected.
splitButtonView
.bind('class')
.toMany(buttonViews, 'isOn', (...areOn) =>
areOn.some(identity) ? 'ck-splitbutton_flatten' : null,
);
splitButtonView.on('execute', () => {
if (!buttonViews.some(({ isOn }) => isOn)) {
defaultButton.fire('execute');
} else {
dropdownView.isOpen = !dropdownView.isOpen;
}
});
dropdownView
.bind('isEnabled')
.toMany(buttonViews, 'isEnabled', (...areEnabled) =>
areEnabled.some(identity),
);
return dropdownView;
});
}
/**
* Creates a button and stores it in the editor component factory.
*
* @param {Drupal.CKEditor5~DrupalElementStyle} buttonConfig
* The button configuration.
* @param {string} group
* The name of the group (e.g. 'align', 'viewMode').
* @param {Drupal.CKEditor5~DrupalElementStyleDefinition[]} definedStyles
* A list of defined styles of one group.
*
* @see module:ui/componentfactory~ComponentFactory
*
* @private
*/
_createButton(buttonConfig, group, definedStyles) {
const buttonName = buttonConfig.name;
this.editor.ui.componentFactory.add(
getUIComponentName(buttonName, group),
(locale) => {
const command = this.editor.commands.get('drupalElementStyle');
const view = new ButtonView(locale);
view.set({
label: buttonConfig.title,
icon: buttonConfig.icon,
tooltip: true,
isToggleable: true,
});
view.bind('isEnabled').to(command, 'isEnabled');
view.bind('isOn').to(command, 'value', (value) => {
return value && value[group] === buttonName;
});
view.on('execute', this._executeCommand.bind(this, buttonName, group));
this.listenTo(this.editor.ui, 'update', () => {
this.updateOptionVisibility(definedStyles, buttonConfig, view, group);
});
return view;
},
);
}
/**
* A helper function that parses the different dropdown options and returns
* list item definitions ready for use in the dropdown.
*
* @param {Drupal.CKEditor5~DrupalElementStyleDefinition[]} definedStyles
* A list of defined styles of one group.
* @param {module:drupalMedia/drupalelementstyle/drupalelementstylecommand} command
* The drupalElementStyle command.
* @param {string} group
* The name of the group (e.g. 'align', 'viewMode').
* @return {Iterable.<module:ui/dropdown/utils~ListDropdownItemDefinition>}
* Dropdown item definitions.
*
* @private
*/
getDropdownListItemDefinitions(definedStyles, command, group) {
const itemDefinitions = new Collection();
definedStyles.forEach((style) => {
const definition = {
type: 'button',
model: new ViewModel({
group,
commandValue: style.name,
label: style.title,
withText: true,
class: '',
}),
};
itemDefinitions.add(definition);
// Handles selecting another element's list dropdown button's visibility.
// We need to listen to editor UI changes instead of selection because
// visibility of the styles can be impacted by either selection or
// changes to the model.
this.listenTo(this.editor.ui, 'update', () => {
this.updateOptionVisibility(definedStyles, style, definition, group);
});
});
return itemDefinitions;
}
/**
* A helper function that creates a list dropdown component.
*
* @param {Drupal.CKEditor5~drupalElementStyleDropdownDefinition} dropdownConfig
* The dropdown configuration.
* @param {Drupal.CKEditor5~DrupalElementStyle[]} definedStyles
* A list of defined styles of one group.
*
* @private
*/
_createListDropdown(dropdownConfig, definedStyles) {
const factory = this.editor.ui.componentFactory;
factory.add(dropdownConfig.name, (locale) => {
let defaultButton;
const { defaultItem, items, title, defaultText } = dropdownConfig;
const group = dropdownConfig.name.split(':')[1];
const buttonViews = items
.filter((itemName) => {
return definedStyles.find(
({ name }) => getUIComponentName(name, group) === itemName,
);
})
.map((buttonName) => {
const button = factory.create(buttonName);
if (buttonName === defaultItem) {
defaultButton = button;
}
return button;
});
if (items.length !== buttonViews.length) {
utils.warnInvalidStyle({ dropdown: dropdownConfig });
}
const dropdownView = createDropdown(locale, DropdownButtonView);
const dropdownButtonView = dropdownView.buttonView;
dropdownButtonView.set({
label: getDropdownButtonTitle(title, defaultButton.label),
class: null,
tooltip: defaultText,
withText: true,
});
const command = this.editor.commands.get('drupalElementStyle');
// If style is selected, use the label of the selected style as the
// default label of the splitbutton.
dropdownButtonView.bind('label').to(command, 'value', (commandValue) => {
if (commandValue?.[group]) {
// eslint-disable-next-line no-restricted-syntax
for (const style of definedStyles) {
if (style.name === commandValue[group]) {
return style.title;
}
}
}
return defaultText;
});
dropdownView.bind('isOn').to(command);
dropdownView.bind('isEnabled').to(this);
addListToDropdown(
dropdownView,
this.getDropdownListItemDefinitions(definedStyles, command, group),
);
// Execute command when an item from the dropdown is selected.
this.listenTo(dropdownView, 'execute', (evt) => {
this._executeCommand(evt.source.commandValue, evt.source.group);
});
return dropdownView;
});
}
/**
* Executes the Drupal Element Style command.
*
* @param {string} name
* The name of the style that should be applied.
* @param {string} group
* The name of the group (e.g. 'align', 'viewMode').
*
* @see module:drupalMedia/drupalelementstyle/drupalelementstylecommand~DrupalElementStyleCommand
*
* @private
*/
_executeCommand(name, group) {
this.editor.execute('drupalElementStyle', {
value: name,
group,
});
this.editor.editing.view.focus();
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalElementStyleUi';
}
}

View File

@ -0,0 +1,70 @@
/* cspell:ignore documentselection */
/**
* Checks the schema to see if drupalElementStyle is supported on the element.
*
* @param {module:engine/model/element~Element|null} selectedElement
* The selected element.
* @param {string[]} modelAttributes
* Array of model attribute keys.
* @param {module:engine/model/schema~Schema} schema
* The model schema.
*
* @return {boolean}
* Whether element supports any of the drupalElementStyle attributes.
*
* @internal
*/
export function elementSupportsDrupalElementStyles(
selectedElement,
modelAttributes,
schema,
) {
// eslint-disable-next-line no-restricted-syntax
for (const modelAttribute of modelAttributes) {
if (schema.checkAttribute(selectedElement, modelAttribute)) {
return true;
}
}
return false;
}
/**
* Gets the closest element with any drupalElementStyle attribute in its schema.
*
* @param {module:engine/model/documentselection~DocumentSelection} selection
* The current document selection.
* @param {module:engine/model/schema~Schema} schema
* The model schema.
* @param {string[]} modelAttributes
* All available Drupal Element Style model attributes.
*
* @return {null|module:engine/model/element~Element}
* The closest element that supports element styles.
*
* @internal
*/
export function getClosestElementWithElementStyleAttribute(
selection,
schema,
modelAttributes,
) {
const selectedElement = selection.getSelectedElement();
if (
selectedElement &&
elementSupportsDrupalElementStyles(selectedElement, modelAttributes, schema)
) {
return selectedElement;
}
let { parent } = selection.getFirstPosition();
while (parent) {
if (parent.is('element')) {
// eslint-disable-next-line no-restricted-syntax
if (elementSupportsDrupalElementStyles(parent, modelAttributes, schema)) {
return parent;
}
}
parent = parent.parent;
}
return null;
}

View File

@ -0,0 +1,25 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupallinkmediaediting drupallinkmediaui */
import { Plugin } from 'ckeditor5/src/core';
import DrupalLinkMediaEditing from './drupallinkmediaediting';
import DrupalLinkMediaUI from './drupallinkmediaui';
/**
* @private
*/
export default class DrupalLinkMedia extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [DrupalLinkMediaEditing, DrupalLinkMediaUI];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalLinkMedia';
}
}

View File

@ -0,0 +1,391 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupallinkmediaediting linkediting linkimageediting linkcommand */
import { Plugin } from 'ckeditor5/src/core';
import { Matcher } from 'ckeditor5/src/engine';
import { toMap } from 'ckeditor5/src/utils';
/**
* Returns the first drupal-media element in a given view element.
*
* @param {module:engine/view/element~Element} viewElement
* The view element.
*
* @return {module:engine/view/element~Element|undefined}
* The first <drupal-media> element or undefined if the element doesn't have
* <drupal-media> as a child element.
*/
function getFirstMedia(viewElement) {
return Array.from(viewElement.getChildren()).find(
(child) => child.name === 'drupal-media',
);
}
/**
* Returns a converter that consumes the `href` attribute if a link contains a <drupal-media>.
*
* @return {Function}
* A function that adds an event listener to upcastDispatcher.
*/
function upcastMediaLink() {
return (dispatcher) => {
dispatcher.on(
'element:a',
(evt, data, conversionApi) => {
const viewLink = data.viewItem;
const mediaInLink = getFirstMedia(viewLink);
if (!mediaInLink) {
return;
}
// There's an <drupal-media> inside an <a> element - we consume it so it
// won't be picked up by the Link plugin.
const consumableAttributes = { attributes: ['href'], name: true };
// Consume the `href` attribute so the default one will not convert it to
// $text attribute.
if (!conversionApi.consumable.consume(viewLink, consumableAttributes)) {
// Might be consumed by something else - i.e. other converter with
// priority=highest - a standard check.
return;
}
const linkHref = viewLink.getAttribute('href');
// Missing the `href` attribute.
if (linkHref === null) {
return;
}
const conversionResult = conversionApi.convertItem(
mediaInLink,
data.modelCursor,
);
// Set media range as conversion result.
data.modelRange = conversionResult.modelRange;
// Continue conversion where <drupal-media> conversion ends.
data.modelCursor = conversionResult.modelCursor;
const modelElement = data.modelCursor.nodeBefore;
if (modelElement && modelElement.is('element', 'drupalMedia')) {
// Set the `linkHref` attribute from <a> element on model drupalMedia
// element.
conversionApi.writer.setAttribute('linkHref', linkHref, modelElement);
}
},
{ priority: 'high' },
);
};
}
/**
* Return a converter that adds the <a> element to view data.
*
* @return {Function}
* A function that adds an event listener to downcastDispatcher.
*/
function dataDowncastMediaLink() {
return (dispatcher) => {
dispatcher.on(
'attribute:linkHref:drupalMedia',
(evt, data, conversionApi) => {
const { writer } = conversionApi;
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
// The drupalMedia will be already converted - so it will be present in
// the view.
const mediaElement = conversionApi.mapper.toViewElement(data.item);
// If so, update the attribute if it's defined or remove the entire link
// if the attribute is empty. But if it does not exist. Let's wrap already
// converted drupalMedia by newly created link element.
// 1. Create an empty <a> element.
const linkElement = writer.createContainerElement('a', {
href: data.attributeNewValue,
});
// 2. Insert <a> before the <drupal-media> element.
writer.insert(writer.createPositionBefore(mediaElement), linkElement);
// 3. Move the drupal-media element inside the <a>.
writer.move(
writer.createRangeOn(mediaElement),
writer.createPositionAt(linkElement, 0),
);
},
{ priority: 'high' },
);
};
}
/**
* Return a converter that adds the <a> element to editing view.
*
* @return {Function}
* A function that adds an event listener to downcastDispatcher.
*
* @see https://github.com/ckeditor/ckeditor5/blob/v31.0.0/packages/ckeditor5-link/src/linkimageediting.js#L180
*/
function editingDowncastMediaLink() {
return (dispatcher) => {
dispatcher.on(
'attribute:linkHref:drupalMedia',
(evt, data, conversionApi) => {
const { writer } = conversionApi;
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
// The drupalMedia will be already converted - so it will be present in
// the view.
const mediaContainer = conversionApi.mapper.toViewElement(data.item);
const linkInMedia = Array.from(mediaContainer.getChildren()).find(
(child) => child.name === 'a',
);
// If link already exists, instead of creating new link from scratch,
// update the existing link. This makes the UI rendering much smoother.
if (linkInMedia) {
// If attribute has a new value, update it. If new value doesn't exist,
// the link will be removed.
if (data.attributeNewValue) {
writer.setAttribute('href', data.attributeNewValue, linkInMedia);
} else {
// This is triggering elementToElement conversion for drupalMedia
// element which makes caused re-render of the media preview, making
// the media preview flicker once when media is unlinked.
// @todo ensure that this doesn't cause flickering after
// https://www.drupal.org/i/3304834 has been addressed.
writer.move(
writer.createRangeIn(linkInMedia),
writer.createPositionAt(mediaContainer, 0),
);
writer.remove(linkInMedia);
}
} else {
const mediaPreview = Array.from(mediaContainer.getChildren()).find(
(child) => child.getAttribute('data-drupal-media-preview'),
);
// 1. Create an empty <a> element.
const linkElement = writer.createContainerElement('a', {
href: data.attributeNewValue,
});
// 2. Insert <a> inside the media container.
writer.insert(
writer.createPositionAt(mediaContainer, 0),
linkElement,
);
// 3. Move the media preview inside the <a>.
writer.move(
writer.createRangeOn(mediaPreview),
writer.createPositionAt(linkElement, 0),
);
}
},
{ priority: 'high' },
);
};
}
/**
* Returns a converter that enables manual decorators on linked Drupal Media.
*
* @see \Drupal\editor\EditorXssFilter\Standard
*
* @param {module:link/link~LinkDecoratorDefinition} decorator
* The link decorator.
* @return {function}
* Function attaching event listener to dispatcher.
*
* @private
*/
function downcastMediaLinkManualDecorator(decorator) {
return (dispatcher) => {
dispatcher.on(
`attribute:${decorator.id}:drupalMedia`,
(evt, data, conversionApi) => {
const mediaContainer = conversionApi.mapper.toViewElement(data.item);
// Scenario 1: `<figure>` element that contains `<a>`, generated by
// `dataDowncast`.
let mediaLink = Array.from(mediaContainer.getChildren()).find(
(child) => child.name === 'a',
);
// Scenario 2: `<drupal-media>` wrapped with `<a>`, generated by
// `editingDowncast`.
if (!mediaLink && mediaContainer.is('element', 'a')) {
mediaLink = mediaContainer;
} else {
mediaLink = Array.from(mediaContainer.getAncestors()).find(
(ancestor) => ancestor.name === 'a',
);
}
// The <a> element was removed by the time this converter is executed.
// It may happen when the base `linkHref` and decorator attributes are
// removed at the same time.
if (!mediaLink) {
return;
}
// eslint-disable-next-line no-restricted-syntax
for (const [key, val] of toMap(decorator.attributes)) {
conversionApi.writer.setAttribute(key, val, mediaLink);
}
if (decorator.classes) {
conversionApi.writer.addClass(decorator.classes, mediaLink);
}
// Add support for `style` attribute in manual decorators to remain
// consistent with CKEditor 5. This only works with text formats that
// have no HTMl filtering enabled.
// eslint-disable-next-line no-restricted-syntax
for (const key in decorator.styles) {
if (Object.prototype.hasOwnProperty.call(decorator.styles, key)) {
conversionApi.writer.setStyle(
key,
decorator.styles[key],
mediaLink,
);
}
}
},
);
};
}
/**
* Returns a converter that applies manual decorators to linked Drupal Media.
*
* @param {module:core/editor/editor~Editor} editor
* The editor.
* @param {module:link/link~LinkDecoratorDefinition} decorator
* The link decorator.
* @return {function}
* Function attaching event listener to dispatcher.
*
* @private
*/
function upcastMediaLinkManualDecorator(editor, decorator) {
return (dispatcher) => {
dispatcher.on(
'element:a',
(evt, data, conversionApi) => {
const viewLink = data.viewItem;
const drupalMediaInLink = getFirstMedia(viewLink);
// We need to check whether Drupal Media is inside a link because the
// converter handles only manual decorators for linked Drupal Media.
if (!drupalMediaInLink) {
return;
}
const matcher = new Matcher(decorator._createPattern());
const result = matcher.match(viewLink);
// The link element does not have required attributes or/and proper
// values.
if (!result) {
return;
}
// Check whether we can consume those attributes.
if (!conversionApi.consumable.consume(viewLink, result.match)) {
return;
}
// At this stage we can assume that we have the `<drupalMedia>` element.
const modelElement = data.modelCursor.nodeBefore;
conversionApi.writer.setAttribute(decorator.id, true, modelElement);
},
{ priority: 'high' },
);
// Using the same priority as the media link upcast converter guarantees
// that the linked `<drupalMedia>` was already converted.
// @see upcastMediaLink().
};
}
/**
* Model to view and view to model conversions for linked media elements.
*
* @private
*
* @see https://github.com/ckeditor/ckeditor5/blob/v31.0.0/packages/ckeditor5-link/src/linkimage.js
*/
export default class DrupalLinkMediaEditing extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return ['LinkEditing', 'DrupalMediaEditing'];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalLinkMediaEditing';
}
/**
* @inheritdoc
*/
init() {
const { editor } = this;
editor.model.schema.extend('drupalMedia', {
allowAttributes: ['linkHref'],
});
editor.conversion.for('upcast').add(upcastMediaLink());
editor.conversion.for('editingDowncast').add(editingDowncastMediaLink());
editor.conversion.for('dataDowncast').add(dataDowncastMediaLink());
this._enableManualDecorators();
const linkCommand = editor.commands.get('link');
if (linkCommand.automaticDecorators.length > 0) {
throw new Error(
'The Drupal Media plugin is not compatible with automatic link decorators. To use Drupal Media, disable any plugins providing automatic link decorators.',
);
}
}
/**
* Processes transformed manual link decorators and attaches proper converters
* that will work when linking Drupal Media.
*
* @see module:link/linkimageediting~LinkImageEditing
* @see module:link/linkcommand~LinkCommand
* @see module:link/utils~ManualDecorator
*
* @private
*/
_enableManualDecorators() {
const editor = this.editor;
const command = editor.commands.get('link');
// eslint-disable-next-line no-restricted-syntax
for (const decorator of command.manualDecorators) {
editor.model.schema.extend('drupalMedia', {
allowAttributes: decorator.id,
});
editor.conversion
.for('downcast')
.add(downcastMediaLinkManualDecorator(decorator));
editor.conversion
.for('upcast')
.add(upcastMediaLinkManualDecorator(editor, decorator));
}
}
}

View File

@ -0,0 +1,110 @@
/* eslint-disable import/no-extraneous-dependencies */
// cspell:ignore linkui
import { Plugin } from 'ckeditor5/src/core';
import { LINK_KEYSTROKE } from '@ckeditor/ckeditor5-link/src/utils';
import { ButtonView } from 'ckeditor5/src/ui';
import linkIcon from '../../../../../icons/link.svg';
/**
* The link media UI plugin.
*
* @private
*/
export default class DrupalLinkMediaUI extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return ['LinkEditing', 'LinkUI', 'DrupalMediaEditing'];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalLinkMediaUi';
}
/**
* @inheritdoc
*/
init() {
const { editor } = this;
const viewDocument = editor.editing.view.document;
this.listenTo(
viewDocument,
'click',
(evt, data) => {
if (this._isSelectedLinkedMedia(editor.model.document.selection)) {
// Prevent browser navigation when clicking a linked media.
data.preventDefault();
// Block the `LinkUI` plugin when a media was clicked. In such a case,
// we'd like to display the media toolbar.
evt.stop();
}
},
{ priority: 'high' },
);
this._createToolbarLinkMediaButton();
}
/**
* Creates a `DrupalLinkMediaUI` button view.
*
* Clicking this button shows a {@link module:link/linkui~LinkUI#_balloon}
* attached to the selection. When an media is already linked, the view shows
* {@link module:link/linkui~LinkUI#actionsView} or
* {@link module:link/linkui~LinkUI#formView} if it is not.
*/
_createToolbarLinkMediaButton() {
const { editor } = this;
editor.ui.componentFactory.add('drupalLinkMedia', (locale) => {
const button = new ButtonView(locale);
const plugin = editor.plugins.get('LinkUI');
const linkCommand = editor.commands.get('link');
button.set({
isEnabled: true,
label: Drupal.t('Link media'),
icon: linkIcon,
keystroke: LINK_KEYSTROKE,
tooltip: true,
isToggleable: true,
});
// Bind button to the command.
button.bind('isEnabled').to(linkCommand, 'isEnabled');
button.bind('isOn').to(linkCommand, 'value', (value) => !!value);
// Show the actionsView or formView (both from LinkUI) on button click
// depending on whether the media is already linked.
this.listenTo(button, 'execute', () => {
if (this._isSelectedLinkedMedia(editor.model.document.selection)) {
plugin._addToolbarView();
} else {
plugin._showUI(true);
}
});
return button;
});
}
/**
* Returns true if a linked media is the only selected element in the model.
*
* @param {module:engine/model/selection~Selection} selection
* @return {Boolean}
*/
// eslint-disable-next-line class-methods-use-this
_isSelectedLinkedMedia(selection) {
const selectedModelElement = selection.getSelectedElement();
return (
selectedModelElement?.is('element', 'drupalMedia') &&
selectedModelElement.hasAttribute('linkHref')
);
}
}

View File

@ -0,0 +1,44 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalmediaediting drupalmediageneralhtmlsupport drupalmediaui drupalmediatoolbar mediaimagetextalternative */
import { Plugin } from 'ckeditor5/src/core';
import DrupalMediaEditing from './drupalmediaediting';
import DrupalMediaUI from './drupalmediaui';
import DrupalMediaToolbar from './drupalmediatoolbar';
import MediaImageTextAlternative from './mediaimagetextalternative';
import DrupalMediaGeneralHtmlSupport from './drupalmediageneralhtmlsupport';
/**
* Main entrypoint to the Drupal media widget.
*
* See individual capabilities for details:
* - {@link DrupalMediaEditing}
* - {@link DrupalMediaGeneralHtmlSupport}
* - {@link DrupalMediaUI}
* - {@link DrupalMediaToolbar}
* - {@link MediaImageTextAlternative}
*
* @private
*/
export default class DrupalMedia extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [
DrupalMediaEditing,
DrupalMediaGeneralHtmlSupport,
DrupalMediaUI,
DrupalMediaToolbar,
MediaImageTextAlternative,
];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalMedia';
}
}

View File

@ -0,0 +1,26 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalmediacaption drupalmediacaptionediting drupalmediacaptionui */
import { Plugin } from 'ckeditor5/src/core';
import DrupalMediaCaptionEditing from './drupalmediacaption/drupalmediacaptionediting';
import DrupalMediaCaptionUI from './drupalmediacaption/drupalmediacaptionui';
/**
* Provides the caption feature on Drupal media elements.
*
* @private
*/
export default class DrupalMediaCaption extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [DrupalMediaCaptionEditing, DrupalMediaCaptionUI];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalMediaCaption';
}
}

View File

@ -0,0 +1,160 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore imagecaption */
import { Command } from 'ckeditor5/src/core';
import { getClosestSelectedDrupalMediaElement, isDrupalMedia } from '../utils';
import { getMediaCaptionFromModelSelection } from './utils';
/**
* Gets the caption model element from the media model selection.
*
* @param {module:engine/model/element~Element} drupalMediaModelElement
* The model element from which caption should be retrieved.
* @returns {module:engine/model/element~Element|null}
* The caption element or `null` if the selection has no child caption
* element.
*/
function getCaptionFromDrupalMediaModelElement(drupalMediaModelElement) {
// eslint-disable-next-line no-restricted-syntax
for (const node of drupalMediaModelElement.getChildren()) {
if (!!node && node.is('element', 'caption')) {
return node;
}
}
return null;
}
/**
* The toggle Drupal Media caption command.
*
* This command either adds or removes the caption of a selected drupalMedia
* element.
*
* This is inspired by the CKEditor 5 image caption plugin.
*
* @see module:image/imagecaption~ImageCaption
*
* @extends module:core/command~Command
*
* @private
*/
export default class ToggleDrupalMediaCaptionCommand extends Command {
/**
* @inheritdoc
*/
refresh() {
const selection = this.editor.model.document.selection;
const selectedElement = selection.getSelectedElement();
// When selectedElement is falsy, it is potentially due to multiple elements
// being selected, such as elements that descend from `<drupalMedia>`.
if (!selectedElement) {
// Command should be enabled if `<drupalMedia>` element is part of the
// selection.
this.isEnabled = !!getClosestSelectedDrupalMediaElement(selection);
// Check if the selection descends from a `<drupalMedia>` element that
// also includes a `<caption>`.
this.value = !!getMediaCaptionFromModelSelection(selection);
return;
}
// If single element is selected, check if it's a `<drupalMedia>` element.
this.isEnabled = isDrupalMedia(selectedElement);
if (!this.isEnabled) {
this.value = false;
} else {
// Command value is set based on whether the selected `<drupalMedia>`
// element has a `<caption>` as a child element.
this.value = !!getCaptionFromDrupalMediaModelElement(selectedElement);
}
}
/**
* Executes the command.
*
* @example
* editor.execute('toggleMediaCaption');
*
* @param {Object} [options]
* Options for the executed command.
* @param {String} [options.focusCaptionOnShow]
* When true and the caption shows up, the selection will be moved into it
* When true: If a caption is present, the selection will be moved to that
* caption immediately.
*
* @fires execute
*/
execute(options = {}) {
const { focusCaptionOnShow } = options;
this.editor.model.change((writer) => {
if (this.value) {
this._hideDrupalMediaCaption(writer);
} else {
this._showDrupalMediaCaption(writer, focusCaptionOnShow);
}
});
}
/**
* Shows the caption of a selected drupalMedia element.
*
* This also attempts to restore the caption content from the
* `DrupalMediaEditing` caption registry. If the `focusCaptionOnShow` option
* is true, the selection is immediately moved to the caption.
*
* @param {module:engine/model/writer~Writer} writer
* The model writer.
* @param {boolean} focusCaptionOnShow
* Flag indicating whether the caption should be focused.
*/
_showDrupalMediaCaption(writer, focusCaptionOnShow) {
const model = this.editor.model;
const selection = model.document.selection;
const mediaCaptionEditing = this.editor.plugins.get(
'DrupalMediaCaptionEditing',
);
const selectedMedia = getClosestSelectedDrupalMediaElement(selection);
const savedCaption = mediaCaptionEditing._getSavedCaption(selectedMedia);
// Try restoring the caption from the DrupalMediaCaptionEditing plugin storage.
const newCaptionElement = savedCaption || writer.createElement('caption');
writer.append(newCaptionElement, selectedMedia);
if (focusCaptionOnShow) {
writer.setSelection(newCaptionElement, 'in');
}
}
/**
* Hides the caption of a selected drupalMedia element.
*
* The content of the caption is stored in the `DrupalMediaCaptionEditing`
* caption registry to make this a reversible action.
*
* @param {module:engine/model/writer~Writer} writer
* The model writer.
*/
_hideDrupalMediaCaption(writer) {
const editor = this.editor;
const selection = editor.model.document.selection;
const mediaCaptionEditing = editor.plugins.get('DrupalMediaCaptionEditing');
let selectedElement = selection.getSelectedElement();
let captionElement;
if (selectedElement) {
captionElement = getCaptionFromDrupalMediaModelElement(selectedElement);
} else {
captionElement = getMediaCaptionFromModelSelection(selection);
selectedElement = getClosestSelectedDrupalMediaElement(selection);
}
// Store the caption content so it can be restored quickly if the user
// changes their mind.
mediaCaptionEditing._saveCaption(selectedElement, captionElement);
writer.setSelection(selectedElement, 'on');
writer.remove(captionElement);
}
}

View File

@ -0,0 +1,306 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore insertdrupalmedia JSONified drupalmediacaptioncommand downcasted */
import { Plugin } from 'ckeditor5/src/core';
import { Element, enablePlaceholder } from 'ckeditor5/src/engine';
import { toWidgetEditable } from 'ckeditor5/src/widget';
import { isDrupalMedia } from '../utils';
import ToggleDrupalMediaCaptionCommand from './drupalmediacaptioncommand';
/**
* A view to model converter for Drupal Media caption.
*
* This upcasts the `data-caption` attribute from `<drupal-media>` elements into
* a `<caption>` model element. This is converted into a model element instead of
* a model attribute in order to leverage CKEditor 5 built-in editing.
*
* @param {module:core/editor/editor~Editor} editor
* Editor on which this converter will be used.
* @return {function}
* A function that attaches converter to the dispatcher.
*/
function viewToModelCaption(editor) {
const converter = (evt, data, conversionApi) => {
const { viewItem } = data;
const { writer, consumable } = conversionApi;
if (
!data.modelRange ||
!consumable.consume(viewItem, { attributes: ['data-caption'] })
) {
return;
}
const caption = writer.createElement('caption');
const drupalMedia = data.modelRange.start.nodeAfter;
// 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 drupalMedia, as a last child.
writer.append(caption, drupalMedia);
};
return (dispatcher) => {
dispatcher.on('element:drupal-media', converter, { priority: 'low' });
};
}
/**
* Gets mapper function for repositioning the `<figcaption>` element.
*
* @param {module:engine/view/view~View} editingView
* The editing view.
* @return {function}
* A mapper callback that moves `<figcaption>` element after the Drupal Media
* preview.
*/
function mapModelPositionToView(editingView) {
return (evt, data) => {
const modelPosition = data.modelPosition;
const parent = modelPosition.parent;
if (!isDrupalMedia(parent)) {
return;
}
const viewElement = data.mapper.toViewElement(parent);
data.viewPosition = editingView.createPositionAt(
viewElement,
modelPosition.offset + 1,
);
};
}
/**
* A model to view converter for Drupal Media caption.
*
* This downcasts the `<caption>` model element into `data-caption` attribute in
* the view.
*
* @param {module:core/editor/editor~Editor} editor
* Editor on which this converter will be used.
* @return {function}
* A function that attaches converter to the dispatcher.
*/
function modelCaptionToCaptionAttribute(editor) {
return (dispatcher) => {
dispatcher.on('insert:caption', (evt, data, conversionApi) => {
const { consumable, writer, mapper } = conversionApi;
if (
!isDrupalMedia(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);
}
});
};
}
/**
* The Drupal Media caption editing plugin.
*
* @extends module:core/plugin~Plugin
*
* @private
*/
export default class DrupalMediaCaptionEditing extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalMediaCaptionEditing';
}
/**
* @inheritdoc
*/
constructor(editor) {
super(editor);
/**
* A map of saved Drupal Media captions and related model elements.
*
* @member {WeakMap.<module:engine/model/element~Element,Object>}
*
* @see _saveCaption
*/
this._savedCaptionsMap = new WeakMap();
}
/**
* @inheritdoc
*/
init() {
const editor = this.editor;
const schema = editor.model.schema;
// Schema configuration.
if (!schema.isRegistered('caption')) {
schema.register('caption', {
allowIn: 'drupalMedia',
allowContentOf: '$block',
isLimit: true,
});
} else {
schema.extend('caption', {
allowIn: 'drupalMedia',
});
}
editor.commands.add(
'toggleMediaCaption',
new ToggleDrupalMediaCaptionCommand(editor),
);
this._setupConversion();
}
/**
* Initializes upcasting and downcasting Drupal Media captions.
*/
_setupConversion() {
const editor = this.editor;
const view = editor.editing.view;
// View -> model converter for the data pipeline.
editor.conversion.for('upcast').add(viewToModelCaption(editor));
// Model -> Editing View converter for the data pipeline.
editor.conversion.for('editingDowncast').elementToElement({
model: 'caption',
view: (modelElement, { writer }) => {
if (!isDrupalMedia(modelElement.parent)) {
return null;
}
const figcaptionElement = writer.createEditableElement('figcaption');
figcaptionElement.placeholder = Drupal.t('Enter media caption');
enablePlaceholder({
view,
element: figcaptionElement,
keepOnFocus: true,
});
return toWidgetEditable(figcaptionElement, writer);
},
});
// The `<caption>` element inside the Drupal Media wrapper is by default
// placed before the preview. This rearranges the elements so that
// `<caption>` is rendered after the preview.
editor.editing.mapper.on(
'modelToViewPosition',
mapModelPositionToView(view),
);
// Model -> Data converter for the data pipeline.
editor.conversion
.for('dataDowncast')
.add(modelCaptionToCaptionAttribute(editor));
}
/**
* Returns the saved caption of a Drupal Media model element.
*
* @param {module:engine/model/element~Element} drupalMediaModelElement
* The model element the caption should be returned for.
* @return {module:engine/model/element~Element|null}
* The model caption element or `null` if there is none.
*/
_getSavedCaption(drupalMediaModelElement) {
const jsonObject = this._savedCaptionsMap.get(drupalMediaModelElement);
return jsonObject ? Element.fromJSON(jsonObject) : null;
}
/**
* Saves Drupal Media element caption to allow restoring it in the future.
*
* A caption is saved every time it gets hidden and/or the type of an Drupal
* Media changes. The user should be able to restore it on demand.
*
* @param {module:engine/model/element~Element} drupalMediaModelElement
* The model element the caption is saved for.
* @param {module:engine/model/element~Element} caption
* The caption model element to be saved.
*
* @see _getSavedCaption
* @see module:engine/model/element~Element#toJSON
*/
_saveCaption(drupalMediaModelElement, caption) {
this._savedCaptionsMap.set(drupalMediaModelElement, caption.toJSON());
}
}

View File

@ -0,0 +1,80 @@
/* eslint-disable import/no-extraneous-dependencies */
import { Plugin } from 'ckeditor5/src/core';
import { IconCaption } from '@ckeditor/ckeditor5-icons';
import { ButtonView } from 'ckeditor5/src/ui';
import { getMediaCaptionFromModelSelection } from './utils';
/**
* The caption media UI plugin.
*
* @private
*/
export default class DrupalMediaCaptionUI extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalMediaCaptionUI';
}
/**
* @inheritdoc
*/
init() {
const { editor } = this;
const editingView = editor.editing.view;
editor.ui.componentFactory.add('toggleDrupalMediaCaption', (locale) => {
const button = new ButtonView(locale);
const captionCommand = editor.commands.get('toggleMediaCaption');
button.set({
label: Drupal.t('Caption media'),
icon: IconCaption,
tooltip: true,
isToggleable: true,
});
// Bind button isOn and isEnabled properties to the command.
button.bind('isOn', 'isEnabled').to(captionCommand, 'value', 'isEnabled');
button
.bind('label')
.to(captionCommand, 'value', (value) =>
value
? Drupal.t('Toggle caption off')
: Drupal.t('Toggle caption on'),
);
this.listenTo(button, 'execute', () => {
editor.execute('toggleMediaCaption', { focusCaptionOnShow: true });
// If a caption is present, highlight it and scroll to the selection.
const modelCaptionElement = getMediaCaptionFromModelSelection(
editor.model.document.selection,
);
if (modelCaptionElement) {
const figcaptionElement =
editor.editing.mapper.toViewElement(modelCaptionElement);
editingView.scrollToTheSelection();
editingView.change((writer) => {
writer.addClass(
'drupal-media__caption_highlighted',
figcaptionElement,
);
});
}
editor.editing.view.focus();
});
return button;
});
}
}

View File

@ -0,0 +1,25 @@
/* eslint-disable import/prefer-default-export */
import { isDrupalMedia } from '../utils';
/**
* Returns the Media caption model element for a model selection.
*
* @param {module:engine/model/selection~Selection} selection
* The current selection.
* @returns {module:engine/model/element~Element|null}
* The Drupal Media caption element for a model selection. Returns null if the
* selection has no Drupal Media caption element ancestor.
*/
export function getMediaCaptionFromModelSelection(selection) {
const captionElement = selection.getFirstPosition().findAncestor('caption');
if (!captionElement) {
return null;
}
if (isDrupalMedia(captionElement.parent)) {
return captionElement;
}
return null;
}

View File

@ -0,0 +1,506 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalmediaediting drupalmediametadatarepository */
/* cspell:ignore imagetextalternative insertdrupalmedia */
/* cspell:ignore insertdrupalmediacommand mediaimagetextalternative */
import { Plugin } from 'ckeditor5/src/core';
import { toWidget, Widget } from 'ckeditor5/src/widget';
import InsertDrupalMediaCommand from './insertdrupalmedia';
import { getPreviewContainer, isDrupalMedia } from './utils';
import { METADATA_ERROR } from './mediaimagetextalternative/utils';
/**
* @module drupalMedia/drupalmediaediting
*/
/**
* The Drupal Media Editing plugin.
*
* Handles the transformation from the CKEditor 5 UI to Drupal-specific markup.
*
* @private
*/
export default class DrupalMediaEditing extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [Widget];
}
constructor(editor) {
super(editor);
this.attrs = {
drupalMediaAlt: 'alt',
drupalMediaEntityType: 'data-entity-type',
drupalMediaEntityUuid: 'data-entity-uuid',
};
this.converterAttributes = [
'drupalMediaEntityUuid',
'drupalElementStyleViewMode',
'drupalMediaEntityType',
'drupalMediaAlt',
];
}
/**
* @inheritdoc
*/
init() {
const options = this.editor.config.get('drupalMedia');
if (!options) {
return;
}
const { previewURL, themeError } = options;
this.previewUrl = previewURL;
this.labelError = Drupal.t('Preview failed');
this.themeError =
themeError ||
`
<p>${Drupal.t(
'An error occurred while trying to preview the media. Save your work and reload this page.',
)}<p>
`;
this._defineSchema();
this._defineConverters();
this._defineListeners();
this.editor.commands.add(
'insertDrupalMedia',
new InsertDrupalMediaCommand(this.editor),
);
}
/**
* Upcast `drupalMediaIsImage` from Drupal Media metadata.
*
* @param {module:engine/model/node~Node} modelElement
* The `drupalMedia` model element.
*
* @see module:drupalMedia/drupalmediametadatarepository~DrupalMediaMetadataRepository
*/
upcastDrupalMediaIsImage(modelElement) {
const { model, plugins } = this.editor;
const metadataRepository = plugins.get('DrupalMediaMetadataRepository');
// Get all metadata for drupalMedia elements to set value for
// drupalMediaIsImage attribute. When other plugins start using the
// metadata, this functionality will be handled more generically.
metadataRepository
.getMetadata(modelElement)
.then((metadata) => {
if (!modelElement) {
// Nothing to do if model element has been removed before
// promise was resolved.
return;
}
// Enqueue a model change that is not visible to the undo/redo feature.
model.enqueueChange({ isUndoable: false }, (writer) => {
writer.setAttribute(
'drupalMediaIsImage',
!!metadata.imageSourceMetadata,
modelElement,
);
});
})
.catch((e) => {
if (!modelElement) {
// Nothing to do if model element has been removed before
// promise was resolved.
return;
}
console.warn(e.toString());
model.enqueueChange({ isUndoable: false }, (writer) => {
writer.setAttribute(
'drupalMediaIsImage',
METADATA_ERROR,
modelElement,
);
});
});
}
/**
* Upcast `drupalMediaType` from Drupal Media metadata.
*
* @param {module:engine/model/node~Node} modelElement
* The `drupalMedia` model element.
*
* @see module:drupalMedia/drupalmediametadatarepository~DrupalMediaMetadataRepository
*
* @private
*/
upcastDrupalMediaType(modelElement) {
const metadataRepository = this.editor.plugins.get(
'DrupalMediaMetadataRepository',
);
// Get all metadata for drupalMedia elements to set value for
// drupalMediaType attribute. When other plugins start using the
// metadata, this functionality will be handled more generically.
metadataRepository
.getMetadata(modelElement)
.then((metadata) => {
if (!modelElement) {
// Nothing to do if model element has been removed before
// promise was resolved.
return;
}
// Enqueue a model change in `transparent` batch to make it
// invisible to the undo/redo functionality.
this.editor.model.enqueueChange({ isUndoable: false }, (writer) => {
writer.setAttribute('drupalMediaType', metadata.type, modelElement);
});
})
.catch((e) => {
if (!modelElement) {
// Nothing to do if model element has been removed before
// promise was resolved.
return;
}
console.warn(e.toString());
this.editor.model.enqueueChange({ isUndoable: false }, (writer) => {
writer.setAttribute('drupalMediaType', METADATA_ERROR, modelElement);
});
});
}
/**
* Fetches preview from the server.
*
* @param {module:engine/model/element~Element} modelElement
* The model element which preview should be loaded.
* @return {Promise<{preview: string, label: string}>}
* A promise that returns an object.
*
* @private
*/
async _fetchPreview(modelElement) {
const query = {
text: this._renderElement(modelElement),
uuid: modelElement.getAttribute('drupalMediaEntityUuid'),
};
const response = await fetch(
`${this.previewUrl}?${new URLSearchParams(query)}`,
{
headers: {
'X-Drupal-MediaPreview-CSRF-Token':
this.editor.config.get('drupalMedia').previewCsrfToken,
},
},
);
if (response.ok) {
const label = response.headers.get('drupal-media-label');
const preview = await response.text();
return { label, preview };
}
return { label: this.labelError, preview: this.themeError };
}
/**
* Registers drupalMedia as a block element in the DOM converter.
*
* @private
*/
_defineSchema() {
const schema = this.editor.model.schema;
schema.register('drupalMedia', {
inheritAllFrom: '$blockObject',
allowAttributes: Object.keys(this.attrs),
});
// Register `<drupal-media>` as a block element in the DOM converter. This
// ensures that the DOM converter knows to handle the `<drupal-media>` as a
// block element.
this.editor.editing.view.domConverter.blockElements.push('drupal-media');
}
/**
* Defines handling of drupal media element in the content lifecycle.
*
* @private
*/
_defineConverters() {
const conversion = this.editor.conversion;
const metadataRepository = this.editor.plugins.get(
'DrupalMediaMetadataRepository',
);
conversion
.for('upcast')
.elementToElement({
view: {
name: 'drupal-media',
},
model: 'drupalMedia',
})
.add((dispatcher) => {
dispatcher.on(
'element:drupal-media',
(evt, data) => {
const [modelElement] = data.modelRange.getItems();
metadataRepository
.getMetadata(modelElement)
.then((metadata) => {
if (!modelElement) {
return;
}
// On upcast, get `drupalMediaIsImage` attribute value from media metadata
// repository.
this.upcastDrupalMediaIsImage(modelElement);
// Enqueue a model change after getting modelElement.
this.editor.model.enqueueChange(
{ isUndoable: false },
(writer) => {
writer.setAttribute(
'drupalMediaType',
metadata.type,
modelElement,
);
},
);
})
.catch((e) => {
// There isn't any UI indication for errors because this should be
// always called after the Drupal Media has been upcast, which would
// already display an error in the UI.
console.warn(e.toString());
});
},
// This converter needs to have the lowest priority to ensure that the
// model element and its attributes have already been converted. It is only used
// to gather metadata to make the UI tailored to the specific media entity that
// is being dealt with.
{ priority: 'lowest' },
);
});
conversion.for('dataDowncast').elementToElement({
model: 'drupalMedia',
view: {
name: 'drupal-media',
},
});
conversion
.for('editingDowncast')
.elementToElement({
model: 'drupalMedia',
view: (modelElement, { writer }) => {
const container = writer.createContainerElement('figure', {
class: 'drupal-media',
});
if (!this.previewUrl) {
// If preview URL isn't available, insert empty preview element
// which indicates that preview couldn't be loaded.
const mediaPreview = writer.createRawElement('div', {
'data-drupal-media-preview': 'unavailable',
});
writer.insert(writer.createPositionAt(container, 0), mediaPreview);
}
writer.setCustomProperty('drupalMedia', true, container);
return toWidget(container, writer, {
label: Drupal.t('Media widget'),
});
},
})
.add((dispatcher) => {
const converter = (event, data, conversionApi) => {
const viewWriter = conversionApi.writer;
const modelElement = data.item;
const container = conversionApi.mapper.toViewElement(data.item);
// Search for preview container recursively from its children because
// the preview container could be wrapped with an element such as
// `<a>`.
let media = getPreviewContainer(container.getChildren());
// Use pre-existing media preview container if one exists. If the
// preview element doesn't exist, create a new element.
if (media) {
// Stop processing if media preview is unavailable or a preview is
// already loading.
if (media.getAttribute('data-drupal-media-preview') !== 'ready') {
return;
}
// Preview was ready meaning that a new preview can be loaded.
// "Change the attribute to loading to prepare for the loading of
// the updated preview. Preview is kept intact so that it can still
// be interacted with via the UI until the new preview has been
// rendered.
viewWriter.setAttribute(
'data-drupal-media-preview',
'loading',
media,
);
} else {
media = viewWriter.createRawElement('div', {
'data-drupal-media-preview': 'loading',
});
viewWriter.insert(viewWriter.createPositionAt(container, 0), media);
}
this._fetchPreview(modelElement).then(({ label, preview }) => {
if (!media) {
// Nothing to do if associated preview wrapped no longer exist.
return;
}
// CKEditor 5 doesn't support async view conversion. Therefore, once
// the promise is fulfilled, the editing view needs to be modified
// manually.
this.editor.editing.view.change((writer) => {
const mediaPreview = writer.createRawElement(
'div',
{ 'data-drupal-media-preview': 'ready', 'aria-label': label },
(domElement) => {
domElement.innerHTML = preview;
},
);
// Insert the new preview before the previous preview element to
// ensure that the location remains same even if it is wrapped
// with another element.
writer.insert(writer.createPositionBefore(media), mediaPreview);
writer.remove(media);
});
});
};
// List all attributes that should trigger re-rendering of the
// preview.
this.converterAttributes.forEach((attribute) => {
dispatcher.on(`attribute:${attribute}:drupalMedia`, converter);
});
return dispatcher;
});
conversion.for('editingDowncast').add((dispatcher) => {
dispatcher.on(
'attribute:drupalElementStyleAlign:drupalMedia',
(evt, data, conversionApi) => {
const alignMapping = {
// This is a map of CSS classes representing Drupal element styles for alignments.
left: 'drupal-media-style-align-left',
right: 'drupal-media-style-align-right',
center: 'drupal-media-style-align-center',
};
const viewElement = conversionApi.mapper.toViewElement(data.item);
const viewWriter = conversionApi.writer;
// If the prior value is alignment related, it should be removed
// whether or not the module property is consumed.
if (alignMapping[data.attributeOldValue]) {
viewWriter.removeClass(
alignMapping[data.attributeOldValue],
viewElement,
);
}
// If the new value is not alignment related, do not proceed.
if (!alignMapping[data.attributeNewValue]) {
return;
}
// The model property is already consumed, do not proceed.
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
// Add the alignment class in the view that corresponds to the value
// of the model's drupalElementStyle property.
viewWriter.addClass(
alignMapping[data.attributeNewValue],
viewElement,
);
},
);
});
// Set attributeToAttribute conversion for all supported attributes.
Object.keys(this.attrs).forEach((modelKey) => {
const attributeMapping = {
model: {
key: modelKey,
name: 'drupalMedia',
},
view: {
name: 'drupal-media',
key: this.attrs[modelKey],
},
};
// Attributes should be rendered only in dataDowncast to avoid having
// unfiltered data-attributes on the Drupal Media widget.
conversion.for('dataDowncast').attributeToAttribute(attributeMapping);
conversion.for('upcast').attributeToAttribute(attributeMapping);
});
}
/**
* Defines behavior when an drupalMedia element is inserted.
*
* Listen to `insertContent` event on the model to set `drupalMediaIsImage`
* and `drupalMediaType` attribute when `drupalMedia` model element is
* inserted directly to the model.
*
* @see module:drupalMedia/insertdrupalmediacommand~InsertDrupalMediaCommand
*
* @private
*/
_defineListeners() {
this.editor.model.on('insertContent', (eventInfo, [modelElement]) => {
if (!isDrupalMedia(modelElement)) {
return;
}
this.upcastDrupalMediaIsImage(modelElement);
// Need to upcast DrupalMediaType to model so it can be used to show
// correct buttons based on bundle.
this.upcastDrupalMediaType(modelElement);
});
}
/**
* MediaFilterController::preview requires the saved element.
*
* Not previewing data-caption since it does not get updated by new changes.
*
* @param {module:engine/model/element~Element} modelElement
* The drupalMedia model element to be converted.
* @return {string}
* The model element converted into HTML.
*/
_renderElement(modelElement) {
// Create model document fragment which contains the model element so that
// it can be stringified using the dataDowncast.
const modelDocumentFragment = this.editor.model.change((writer) => {
const modelDocumentFragment = writer.createDocumentFragment();
// Create shallow clone of the model element to ensure that the original
// model element remains untouched and that the caption is not rendered
// into the preview.
const clonedModelElement = writer.cloneElement(modelElement, false);
// Remove attributes from the model element to ensure they are not
// downcast into the preview request. For example, the `linkHref` model
// attribute would downcast into a wrapping `<a>` element, which the
// preview endpoint would not be able to handle.
const attributeIgnoreList = ['linkHref'];
attributeIgnoreList.forEach((attribute) => {
writer.removeAttribute(attribute, clonedModelElement);
});
writer.append(clonedModelElement, modelDocumentFragment);
return modelDocumentFragment;
});
return this.editor.data.stringify(modelDocumentFragment);
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalMediaEditing';
}
}

View File

@ -0,0 +1,242 @@
/* eslint-disable import/no-extraneous-dependencies */
// cspell:ignore datafilter eventinfo downcastdispatcher generalhtmlsupport
import { Plugin } from 'ckeditor5/src/core';
import { setViewAttributes } from '@ckeditor/ckeditor5-html-support/src/utils';
/**
* View-to-model conversion helper for Drupal Media.
* Used for preserving allowed attributes on the Drupal Media model.
*
* @param {module:html-support/datafilter~DataFilter} dataFilter
* The General HTML support data filter.
*
* @return {function}
* Function that adds an event listener to upcastDispatcher.
*/
function viewToModelDrupalMediaAttributeConverter(dataFilter) {
return (dispatcher) => {
dispatcher.on(
'element:drupal-media',
(evt, data, conversionApi) => {
function preserveElementAttributes(viewElement, attributeName) {
const viewAttributes = dataFilter.processViewAttributes(
viewElement,
conversionApi,
);
if (viewAttributes) {
conversionApi.writer.setAttribute(
attributeName,
viewAttributes,
data.modelRange,
);
}
}
function preserveLinkAttributes(linkElement) {
preserveElementAttributes(linkElement, 'htmlLinkAttributes');
}
const viewMediaElement = data.viewItem;
const viewContainerElement = viewMediaElement.parent;
preserveElementAttributes(viewMediaElement, 'htmlAttributes');
if (viewContainerElement.is('element', 'a')) {
preserveLinkAttributes(viewContainerElement);
}
},
{ priority: 'low' },
);
};
}
/**
* Gets descendant element from a container.
*
* @param {module:engine/model/writer~Writer} writer
* The writer.
* @param {module:engine/view/element~Element} containerElement
* The container element.
* @param {string} elementName
* The element name.
* @return {module:engine/view/element~Element|undefined}
* The descendant element matching element name or undefined if not found.
*/
function getDescendantElement(writer, containerElement, elementName) {
const range = writer.createRangeOn(containerElement);
// eslint-disable-next-line no-restricted-syntax
for (const { item } of range.getWalker()) {
if (item.is('element', elementName)) {
return item;
}
}
}
/**
* Model to view converter for the Drupal Media wrapper attributes.
*
* @param {module:utils/eventinfo~EventInfo} evt
* An object containing information about the fired event.
* @param {Object} data
* Additional information about the change.
* @param {module:engine/conversion/downcastdispatcher~DowncastDispatcher} conversionApi
* Conversion interface to be used by the callback.
*/
function modelToDataAttributeConverter(evt, data, conversionApi) {
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
const viewElement = conversionApi.mapper.toViewElement(data.item);
setViewAttributes(conversionApi.writer, data.attributeNewValue, viewElement);
}
/**
* Model to editing view attribute converter.
*
* @return {function}
* A function that adds an event listener to downcastDispatcher.
*/
function modelToEditingViewAttributeConverter() {
return (dispatcher) => {
dispatcher.on(
'attribute:linkHref:drupalMedia',
(evt, data, conversionApi) => {
if (
!conversionApi.consumable.consume(
data.item,
'attribute:htmlLinkAttributes:drupalMedia',
)
) {
return;
}
const containerElement = conversionApi.mapper.toViewElement(data.item);
const viewElement = getDescendantElement(
conversionApi.writer,
containerElement,
'a',
);
setViewAttributes(
conversionApi.writer,
data.item.getAttribute('htmlLinkAttributes'),
viewElement,
);
},
{ priority: 'low' },
);
};
}
/**
* Model to data view attribute converter.
*
* @return {function}
* Function that adds an event listener to downcastDispatcher.
*/
function modelToDataViewAttributeConverter() {
return (dispatcher) => {
dispatcher.on(
'attribute:linkHref:drupalMedia',
(evt, data, conversionApi) => {
if (
!conversionApi.consumable.consume(
data.item,
'attribute:htmlLinkAttributes:drupalMedia',
)
) {
return;
}
const mediaElement = conversionApi.mapper.toViewElement(data.item);
const linkElement = mediaElement.parent;
setViewAttributes(
conversionApi.writer,
data.item.getAttribute('htmlLinkAttributes'),
linkElement,
);
},
{ priority: 'low' },
);
dispatcher.on(
'attribute:htmlAttributes:drupalMedia',
modelToDataAttributeConverter,
{ priority: 'low' },
);
};
}
/**
* Integrates Drupal Media with General HTML Support.
*
* @private
*/
export default class DrupalMediaGeneralHtmlSupport extends Plugin {
/**
* @inheritdoc
*/
constructor(editor) {
super(editor);
// This plugin is only needed if General HTML Support plugin is loaded.
if (!editor.plugins.has('GeneralHtmlSupport')) {
return;
}
// This plugin works only if `DataFilter` and `DataSchema` plugins are
// loaded. These plugins are dependencies of `GeneralHtmlSupport` meaning
// that these should be available always when `GeneralHtmlSupport` is
// enabled.
if (
!editor.plugins.has('DataFilter') ||
!editor.plugins.has('DataSchema')
) {
console.error(
'DataFilter and DataSchema plugins are required for Drupal Media to integrate with General HTML Support plugin.',
);
}
const { schema } = editor.model;
const { conversion } = editor;
const dataFilter = this.editor.plugins.get('DataFilter');
const dataSchema = this.editor.plugins.get('DataSchema');
// This needs to be initialized in ::constructor() to ensure this runs
// before the General HTML Support has been initialized.
// @see module:html-support/generalhtmlsupport~GeneralHtmlSupport
dataSchema.registerBlockElement({
model: 'drupalMedia',
view: 'drupal-media',
});
dataFilter.on('register:drupal-media', (evt, definition) => {
if (definition.model !== 'drupalMedia') {
return;
}
schema.extend('drupalMedia', {
allowAttributes: ['htmlLinkAttributes', 'htmlAttributes'],
});
conversion
.for('upcast')
.add(viewToModelDrupalMediaAttributeConverter(dataFilter));
conversion
.for('editingDowncast')
.add(modelToEditingViewAttributeConverter());
conversion.for('dataDowncast').add(modelToDataViewAttributeConverter());
evt.stop();
});
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalMediaGeneralHtmlSupport';
}
}

View File

@ -0,0 +1,99 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalmediametadatarepository */
import { Plugin } from 'ckeditor5/src/core';
/**
* @module drupalMedia/drupalmediametadatarepository
*/
/**
* Fetch metadata from the backend.
*
* @param {string} url
* The URL used for retrieving the metadata.
* @return {Promise<Object>}
* Promise containing response content.
*
* @private
*/
const _fetchMetadata = async (url) => {
const response = await fetch(url);
if (response.ok) {
return JSON.parse(await response.text());
}
throw new Error('Fetching media embed metadata from the server failed.');
};
/**
* @private
*/
export default class DrupalMediaMetadataRepository extends Plugin {
/**
* @inheritdoc
*/
init() {
this._data = new WeakMap();
}
/**
* Gets metadata for a `drupalMedia` model element.
*
* @param {module:engine/model/element~Element} modelElement
* The model element from which metadata should be retrieved.
*
* @return {Promise<Object>}
*/
getMetadata(modelElement) {
// If metadata was retrieved earlier for the model element, return the
// cached value.
if (this._data.get(modelElement)) {
return new Promise((resolve) => {
resolve(this._data.get(modelElement));
});
}
const options = this.editor.config.get('drupalMedia');
if (!options) {
return new Promise((resolve, reject) => {
reject(
new Error(
'drupalMedia configuration is required for parsing metadata.',
),
);
});
}
if (!modelElement.hasAttribute('drupalMediaEntityUuid')) {
return new Promise((resolve, reject) => {
reject(
new Error(
'drupalMedia element must have drupalMediaEntityUuid attribute to retrieve metadata.',
),
);
});
}
const { metadataUrl } = options;
const query = new URLSearchParams({
uuid: modelElement.getAttribute('drupalMediaEntityUuid'),
});
// The `metadataUrl` received from the server already includes a query
// string (for the CSRF token).
// @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media::getDynamicPluginConfig()
const url = `${metadataUrl}&${query}`;
return _fetchMetadata(url).then((metadata) => {
this._data.set(modelElement, metadata);
return metadata;
});
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalMediaMetadataRepository';
}
}

View File

@ -0,0 +1,63 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalmediatoolbar */
import { Plugin } from 'ckeditor5/src/core';
import { WidgetToolbarRepository } from 'ckeditor5/src/widget';
import { getClosestSelectedDrupalMediaWidget, isObject } from './utils';
/**
* @module drupalMedia/drupalmediatoolbar
*/
/**
* Convert dropdown definitions to keys registered in the ComponentFactory.
*
* The registration process should be handled by the plugin which handles the UI
* of a particular feature.
*
* @param {Array.<string|Object>} config
* The drupalMedia.toolbar configuration.
*
* @return {string[]}
* A normalized toolbar item list.
*/
function normalizeDeclarativeConfig(config) {
return config.map((item) => (isObject(item) ? item.name : item));
}
/**
* @private
*/
export default class DrupalMediaToolbar extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [WidgetToolbarRepository];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'DrupalMediaToolbar';
}
/**
* @inheritdoc
*/
afterInit() {
const { editor } = this;
const widgetToolbarRepository = editor.plugins.get(WidgetToolbarRepository);
widgetToolbarRepository.register('drupalMedia', {
ariaLabel: Drupal.t('Drupal Media toolbar'),
items:
normalizeDeclarativeConfig(editor.config.get('drupalMedia.toolbar')) ||
[],
// Get the selected image or an image containing the figcaption with the selection inside.
getRelatedElement: (selection) =>
getClosestSelectedDrupalMediaWidget(selection),
});
}
}

View File

@ -0,0 +1,51 @@
/* eslint-disable import/no-extraneous-dependencies */
// cspell:ignore medialibrary
import { Plugin } from 'ckeditor5/src/core';
import { ButtonView } from 'ckeditor5/src/ui';
// cspell:ignore medialibrary
import mediaIcon from '../theme/icons/medialibrary.svg';
/**
* Provides the toolbar button to insert a Drupal media element.
*
* @private
*/
export default class DrupalMediaUI extends Plugin {
init() {
const editor = this.editor;
const options = this.editor.config.get('drupalMedia');
if (!options) {
return;
}
const { libraryURL, openDialog, dialogSettings = {} } = options;
if (!libraryURL || typeof openDialog !== 'function') {
return;
}
editor.ui.componentFactory.add('drupalMedia', (locale) => {
const command = editor.commands.get('insertDrupalMedia');
const buttonView = new ButtonView(locale);
buttonView.set({
label: Drupal.t('Insert Media'),
icon: mediaIcon,
tooltip: true,
});
buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled');
this.listenTo(buttonView, 'execute', () => {
openDialog(
libraryURL,
({ attributes }) => {
editor.execute('insertDrupalMedia', attributes);
},
dialogSettings,
);
});
return buttonView;
});
}
}

View File

@ -0,0 +1,25 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalelementstyle drupallinkmedia drupalmediacaption */
/* cspell:ignore mediaimagetextalternativeediting mediaimagetextalternativeui */
/* cspell:ignore mediaimagetextalternative */
import DrupalMedia from './drupalmedia';
import DrupalLinkMedia from './drupallinkmedia/drupallinkmedia';
import DrupalElementStyle from './drupalelementstyle';
import DrupalMediaCaption from './drupalmediacaption';
import MediaImageTextAlternative from './mediaimagetextalternative';
import MediaImageTextAlternativeEditing from './mediaimagetextalternative/mediaimagetextalternativeediting';
import MediaImageTextAlternativeUi from './mediaimagetextalternative/mediaimagetextalternativeui';
/**
* @private
*/
export default {
DrupalMedia,
MediaImageTextAlternative,
MediaImageTextAlternativeEditing,
MediaImageTextAlternativeUi,
DrupalLinkMedia,
DrupalMediaCaption,
DrupalElementStyle,
};

View File

@ -0,0 +1,103 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalelementstyle drupalelementstyleediting */
/* cspell:ignore insertdrupalmediacommand */
import { Command } from 'ckeditor5/src/core';
import { groupNameToModelAttributeKey } from './utils';
/**
* @module drupalMedia/insertdrupalmediacommand
*/
function createDrupalMedia(writer, attributes) {
const drupalMedia = writer.createElement('drupalMedia', attributes);
return drupalMedia;
}
/**
* The insert media command.
*
* The command is registered by the `DrupalMediaEditing` plugin as
* `insertDrupalMedia`.
*
* In order to insert media at the current selection position, execute the
* command and pass the attributes desired in the drupal-media element:
*
* @example
* editor.execute('insertDrupalMedia', {
* 'alt': 'Alt text',
* 'data-align': 'left',
* 'data-caption': 'Caption text',
* 'data-entity-type': 'media',
* 'data-entity-uuid': 'media-entity-uuid',
* 'data-view-mode': 'default',
* });
*
* @private
*/
export default class InsertDrupalMediaCommand extends Command {
execute(attributes) {
const mediaEditing = this.editor.plugins.get('DrupalMediaEditing');
// Create object that contains supported data-attributes in view data by
// flipping `DrupalMediaEditing.attrs` object (i.e. keys from object become
// values and values from object become keys).
const dataAttributeMapping = Object.entries(mediaEditing.attrs).reduce(
(result, [key, value]) => {
result[value] = key;
return result;
},
{},
);
// This converts data-attribute keys to keys used in model.
const modelAttributes = Object.keys(attributes).reduce(
(result, attribute) => {
if (dataAttributeMapping[attribute]) {
result[dataAttributeMapping[attribute]] = attributes[attribute];
}
return result;
},
{},
);
// Check if there's Drupal Element Style matching the default attributes on
// the media.
// @see module:drupalMedia/drupalelementstyle/drupalelementstyleediting~DrupalElementStyleEditing
if (this.editor.plugins.has('DrupalElementStyleEditing')) {
const elementStyleEditing = this.editor.plugins.get(
'DrupalElementStyleEditing',
);
const { normalizedStyles } = elementStyleEditing;
// eslint-disable-next-line no-restricted-syntax
for (const group of Object.keys(normalizedStyles)) {
// eslint-disable-next-line no-restricted-syntax
for (const style of elementStyleEditing.normalizedStyles[group]) {
if (
attributes[style.attributeName] &&
style.attributeValue === attributes[style.attributeName]
) {
const modelAttribute = groupNameToModelAttributeKey(group);
modelAttributes[modelAttribute] = style.name;
}
}
}
}
this.editor.model.change((writer) => {
this.editor.model.insertObject(
createDrupalMedia(writer, modelAttributes),
);
});
}
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const allowedIn = model.schema.findAllowedParent(
selection.getFirstPosition(),
'drupalMedia',
);
this.isEnabled = allowedIn !== null;
}
}

View File

@ -0,0 +1,27 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore mediaimagetextalternative mediaimagetextalternativeediting */
/* cspell:ignore mediaimagetextalternativeui */
import { Plugin } from 'ckeditor5/src/core';
import MediaImageTextAlternativeEditing from './mediaimagetextalternative/mediaimagetextalternativeediting';
import MediaImageTextAlternativeUi from './mediaimagetextalternative/mediaimagetextalternativeui';
/**
* The media image text alternative plugin.
*
* @private
*/
export default class MediaImageTextAlternative extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [MediaImageTextAlternativeEditing, MediaImageTextAlternativeUi];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'MediaImageTextAlternative';
}
}

View File

@ -0,0 +1,61 @@
/* eslint-disable import/no-extraneous-dependencies */
import { Command } from 'ckeditor5/src/core';
import { getClosestSelectedDrupalMediaElement } from '../utils';
import { METADATA_ERROR } from './utils';
/**
* The media image text alternative command.
*
* This is used to change the `alt` attribute of `<drupalMedia>` elements.
*
* @see https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativecommand.js
*/
export default class MediaImageTextAlternativeCommand extends Command {
/**
* The command value: `false` if there is no `alt` attribute, otherwise the value of the `alt` attribute.
/**
* @inheritdoc
*/
refresh() {
const drupalMediaElement = getClosestSelectedDrupalMediaElement(
this.editor.model.document.selection,
);
this.isEnabled =
drupalMediaElement?.getAttribute('drupalMediaIsImage') &&
drupalMediaElement.getAttribute('drupalMediaIsImage') !== METADATA_ERROR;
if (this.isEnabled) {
this.value = drupalMediaElement.getAttribute('drupalMediaAlt');
} else {
this.value = false;
}
}
/**
* Executes the command.
*
* @param {Object} options
* An options object.
* @param {String} options.newValue The new value of the `alt` attribute to set.
*/
execute(options) {
const { model } = this.editor;
const drupalMediaElement = getClosestSelectedDrupalMediaElement(
model.document.selection,
);
options.newValue = options.newValue.trim();
model.change((writer) => {
if (options.newValue.length > 0) {
writer.setAttribute(
'drupalMediaAlt',
options.newValue,
drupalMediaElement,
);
} else {
writer.removeAttribute('drupalMediaAlt', drupalMediaElement);
}
});
}
}

View File

@ -0,0 +1,129 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore drupalmediametadatarepository insertdrupalmediacommand */
/* cspell:ignore mediaimagetextalternative mediaimagetextalternativecommand */
/* cspell:ignore mediaimagetextalternativeediting */
import { Plugin } from 'ckeditor5/src/core';
import { Template } from 'ckeditor5/src/ui';
import MediaImageTextAlternativeCommand from './mediaimagetextalternativecommand';
import DrupalMediaMetadataRepository from '../drupalmediametadatarepository';
import { METADATA_ERROR } from './utils';
/**
* @module drupalMedia/mediaimagetextalternative/mediaimagetextalternativeediting
*/
/**
* The media image text alternative editing plugin.
*/
export default class MediaImageTextAlternativeEditing extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [DrupalMediaMetadataRepository];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'MediaImageTextAlternativeEditing';
}
/**
* @inheritdoc
*/
init() {
const {
editor,
editor: { model, conversion },
} = this;
model.schema.extend('drupalMedia', {
allowAttributes: ['drupalMediaIsImage'],
});
// Display error in the editor if fetching Drupal Media metadata failed.
conversion.for('editingDowncast').add((dispatcher) => {
dispatcher.on(
'attribute:drupalMediaIsImage',
(event, data, conversionApi) => {
const { writer, mapper } = conversionApi;
const container = mapper.toViewElement(data.item);
if (data.attributeNewValue !== METADATA_ERROR) {
const existingError = Array.from(container.getChildren()).find(
(child) => child.getCustomProperty('drupalMediaMetadataError'),
);
// If the view contains an existing error, it should be removed
// since retrieving metadata was successful.
if (existingError) {
writer.setCustomProperty(
'widgetLabel',
existingError.getCustomProperty(
'drupalMediaOriginalWidgetLabel',
),
existingError,
);
writer.removeElement(existingError);
}
return;
}
const message = Drupal.t(
'Not all functionality may be available because some information could not be retrieved.',
);
const html = new Template({
tag: 'span',
children: [
{
tag: 'span',
attributes: {
class: 'drupal-media__metadata-error-icon',
'data-cke-tooltip-text': message,
},
},
],
}).render();
const error = writer.createRawElement(
'div',
{
class: 'drupal-media__metadata-error',
},
(domElement, domConverter) => {
domConverter.setContentOf(domElement, html.outerHTML);
},
);
writer.setCustomProperty('drupalMediaMetadataError', true, error);
// Edit widget label to ensure the current status of media embed is
// available for screen reader users.
const originalWidgetLabel =
container.getCustomProperty('widgetLabel');
writer.setCustomProperty(
'drupalMediaOriginalWidgetLabel',
originalWidgetLabel,
error,
);
writer.setCustomProperty(
'widgetLabel',
`${originalWidgetLabel} (${message})`,
container,
);
writer.insert(writer.createPositionAt(container, 0), error);
},
{ priority: 'low' },
);
});
editor.commands.add(
'mediaImageTextAlternative',
new MediaImageTextAlternativeCommand(this.editor),
);
}
}

View File

@ -0,0 +1,260 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore imagetextalternative mediaimagetextalternative */
/* cspell:ignore mediaimagetextalternativeediting textalternativeformview */
import { Plugin } from 'ckeditor5/src/core';
import { IconLowVision } from '@ckeditor/ckeditor5-icons';
import {
ButtonView,
ContextualBalloon,
clickOutsideHandler,
} from 'ckeditor5/src/ui';
import { getClosestSelectedDrupalMediaWidget, isDrupalMedia } from '../utils';
import {
getBalloonPositionData,
repositionContextualBalloon,
} from '../ui/utils';
import TextAlternativeFormView from './ui/textalternativeformview';
/**
* The media image text alternative UI plugin.
*
* @see https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.js
*/
export default class MediaImageTextAlternativeUi extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return [ContextualBalloon];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'MediaImageTextAlternativeUi';
}
/**
* @inheritdoc
*/
init() {
this._createButton();
this._createForm();
}
/**
* @inheritdoc
*/
destroy() {
super.destroy();
this._form.destroy();
}
/**
* Creates a button showing the balloon panel for changing the image text
* alternative and registers it in the editor ComponentFactory.
*/
_createButton() {
const editor = this.editor;
editor.ui.componentFactory.add('mediaImageTextAlternative', (locale) => {
const command = editor.commands.get('mediaImageTextAlternative');
const view = new ButtonView(locale);
view.set({
label: Drupal.t('Override media image alternative text'),
icon: IconLowVision,
tooltip: true,
});
view.bind('isVisible').to(command, 'isEnabled');
this.listenTo(view, 'execute', () => {
this._showForm();
});
return view;
});
}
/**
* Creates the {@link module:image/imagetextalternative/ui/textalternativeformview~TextAlternativeFormView}
* form.
*
* @private
*/
_createForm() {
const editor = this.editor;
const view = editor.editing.view;
const viewDocument = view.document;
/**
* The contextual balloon plugin instance.
*/
this._balloon = this.editor.plugins.get('ContextualBalloon');
/**
* A form containing a textarea and buttons, used to change the `alt` text value.
*/
this._form = new TextAlternativeFormView(editor.locale);
// Render the form so its #element is available for clickOutsideHandler.
this._form.render();
this.listenTo(this._form, 'submit', () => {
editor.execute('mediaImageTextAlternative', {
// The "decorative toggle" allows users to opt-in to empty alt
// attributes for the very rare edge cases where that is valid. This is
// indicated by specifying two double quotes as the alternative text.
// See https://www.w3.org/WAI/tutorials/images/decorative .
newValue: this._form.decorativeToggle.isOn
? '""'
: this._form.labeledInput.fieldView.element.value,
});
this._hideForm(true);
});
this.listenTo(this._form, 'cancel', () => {
this._hideForm(true);
});
// 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 a media widget is no longer
// selected.
this.listenTo(editor.ui, 'update', () => {
if (!getClosestSelectedDrupalMediaWidget(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 a balloon.
*/
_showForm() {
if (this._isVisible) {
return;
}
const editor = this.editor;
const command = editor.commands.get('mediaImageTextAlternative');
const decorativeToggle = this._form.decorativeToggle;
const metadataRepository = editor.plugins.get(
'DrupalMediaMetadataRepository',
);
const labeledInput = this._form.labeledInput;
this._form.disableCssTransitions();
if (!this._isInBalloon) {
this._balloon.add({
view: this._form,
position: getBalloonPositionData(editor),
});
}
// This implementation, populating double quotes, differs from drupalImage.
// In drupalImage, an image either has alt text or it is decorative, so the
// 'decorative' state can be represented by an empty string. In drupalMedia,
// an image can inherit alt text from the media entity (represented by an
// empty string), can have overridden alt text (represented by user-entered
// text), or can be designated decorative (represented by double quotes).
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;
this._form.defaultAltText = '';
const modelElement = editor.model.document.selection.getSelectedElement();
// Make sure that each time the panel shows up, the default alt text remains
// in sync with the value from the metadata repository.
if (isDrupalMedia(modelElement)) {
metadataRepository
.getMetadata(modelElement)
.then((metadata) => {
this._form.defaultAltText = metadata.imageSourceMetadata
? metadata.imageSourceMetadata.alt
: '';
labeledInput.infoText = Drupal.t(
`Leave blank to use the default alternative text: "${this._form.defaultAltText}".`,
);
})
.catch((e) => {
// There isn't any UI indication for errors because this should be
// always called after the Drupal Media has been upcast, which would
// already display an error in the UI.
// @see module:drupalMedia/mediaimagetextalternative/mediaimagetextalternativeediting~MediaImageTextAlternativeEditing
console.warn(e.toString());
});
}
this._form.enableCssTransitions();
}
/**
* Removes the {@link #_form} from the {@link #_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}
*/
get _isVisible() {
return this._balloon.visibleView === this._form;
}
/**
* Returns `true` when the form is in the balloon.
*
* @type {Boolean}
*/
get _isInBalloon() {
return this._balloon.hasView(this._form);
}
}

View File

@ -0,0 +1,213 @@
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:ignore focusables switchbuttonview */
import {
ButtonView,
FocusCycler,
LabeledFieldView,
SwitchButtonView,
View,
ViewCollection,
createLabeledInputText,
injectCssTransitionDisabler,
submitHandler,
Template,
} from 'ckeditor5/src/ui';
import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils';
import { IconCheck, IconCancel } from '@ckeditor/ckeditor5-icons';
export default class TextAlternativeFormView extends View {
/**
* @inheritdoc
*/
constructor(locale) {
super(locale);
/**
* Tracks information about the DOM focus in the form.
*/
this.focusTracker = new FocusTracker();
/**
* An instance of the 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.
*/
this.labeledInput = this._createLabeledInputView();
/**
* A button used to submit the form.
*/
this.saveButtonView = this._createButton(
Drupal.t('Save'),
IconCheck,
'ck-button-save',
);
this.saveButtonView.type = 'submit';
/**
* A button used to cancel the form.
*/
this.cancelButtonView = this._createButton(
Drupal.t('Cancel'),
IconCancel,
'ck-button-cancel',
'cancel',
);
/**
* A collection of views which can be focused in the form.
*/
this._focusables = new ViewCollection();
/**
* Helps cycling over focusables in the form.
*/
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-media-alternative-text-form', 'ck-vertical-form'],
tabindex: '-1',
},
children: [
{
tag: 'div',
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);
});
}
/**
* 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.
* @return {module:ui/view~View}
* The button view instance.
*/
_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.
*
* @return {module:ui/view~View}
* Labeled field view instance.
*/
_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 override');
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', () => {
if (decorativeToggle.isOn) {
// Clear value when decorative alt is turned off.
this.labeledInput.fieldView.element.value = '';
}
decorativeToggle.set('isOn', !decorativeToggle.isOn);
});
return decorativeToggle;
}
}

View File

@ -0,0 +1,9 @@
/* eslint-disable import/prefer-default-export */
/**
* Used for indicating metadata errors in model.
*
* @type {string}
*
* @see \Drupal\ckeditor5\Controller\CKEditor5MediaController
*/
export const METADATA_ERROR = 'METADATA_ERROR';

View File

@ -0,0 +1,55 @@
/* eslint-disable import/no-extraneous-dependencies */
import { BalloonPanelView } from 'ckeditor5/src/ui';
import { getClosestSelectedDrupalMediaWidget } from '../utils';
/**
* Returns the positioning options that control the geometry of the contextual
* balloon with respect to the selected element in the editor content.
*
* @param {module:core/editor/editor~Editor} editor
* The editor instance.
* @return {Object}
* The options.
*
* @private
*/
export function getBalloonPositionData(editor) {
const editingView = editor.editing.view;
const defaultPositions = BalloonPanelView.defaultPositions;
return {
target: editingView.domConverter.viewToDom(
editingView.document.selection.getSelectedElement(),
),
positions: [
defaultPositions.northArrowSouth,
defaultPositions.northArrowSouthWest,
defaultPositions.northArrowSouthEast,
defaultPositions.southArrowNorth,
defaultPositions.southArrowNorthWest,
defaultPositions.southArrowNorthEast,
],
};
}
/**
* A helper utility that positions the contextual balloon instance with respect
* to the image in the editor content, if one is selected.
*
* @param {module:core/editor/editor~Editor} editor
* The editor instance.
*
* @private
*/
export function repositionContextualBalloon(editor) {
const balloon = editor.plugins.get('ContextualBalloon');
if (
getClosestSelectedDrupalMediaWidget(editor.editing.view.document.selection)
) {
const position = getBalloonPositionData(editor);
balloon.updatePosition(position);
}
}

View File

@ -0,0 +1,150 @@
/* eslint-disable import/no-extraneous-dependencies */
// cspell:ignore documentselection
import { isWidget } from 'ckeditor5/src/widget';
/**
* Checks if the provided model element is `drupalMedia`.
*
* @param {module:engine/model/element~Element} modelElement
* The model element to be checked.
* @return {boolean}
* A boolean indicating if the element is a drupalMedia element.
*
* @private
*/
export function isDrupalMedia(modelElement) {
return !!modelElement && modelElement.is('element', 'drupalMedia');
}
/**
* Checks if view element is <drupal-media> element.
*
* @param {module:engine/view/element~Element} viewElement
* The view element.
* @return {boolean}
* A boolean indicating if the element is a <drupal-media> element.
*
* @private
*/
export function isDrupalMediaWidget(viewElement) {
return (
isWidget(viewElement) && !!viewElement.getCustomProperty('drupalMedia')
);
}
/**
* Gets `drupalMedia` element from selection.
*
* @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
* The current selection.
* @return {module:engine/model/element~Element|null}
* The `drupalMedia` element which could be either the current selected an
* ancestor of the selection. Returns null if the selection has no Drupal
* Media element.
*
* @private
*/
export function getClosestSelectedDrupalMediaElement(selection) {
const selectedElement = selection.getSelectedElement();
return isDrupalMedia(selectedElement)
? selectedElement
: selection.getFirstPosition().findAncestor('drupalMedia');
}
/**
* Gets selected Drupal Media widget if only Drupal Media is currently selected.
*
* @param {module:engine/model/selection~Selection} selection
* The current selection.
* @return {module:engine/view/element~Element|null}
* The currently selected Drupal Media widget or null.
*
* @private
*/
export function getClosestSelectedDrupalMediaWidget(selection) {
const viewElement = selection.getSelectedElement();
if (viewElement && isDrupalMediaWidget(viewElement)) {
return viewElement;
}
// Perhaps nothing is selected.
if (selection.getFirstPosition() === null) {
return null;
}
let parent = selection.getFirstPosition().parent;
while (parent) {
if (parent.is('element') && isDrupalMediaWidget(parent)) {
return parent;
}
parent = parent.parent;
}
return null;
}
/**
* Checks if value is a JavaScript object.
*
* This will return true for any type of JavaScript object. (e.g. arrays,
* functions, objects, regexes, new Number(0), and new String(''))
*
* @param value
* Value to check.
* @return {boolean}
* True if value is an object, else false.
*/
export function isObject(value) {
const type = typeof value;
return value != null && (type === 'object' || type === 'function');
}
/**
* Gets the preview container element from the media element.
*
* @param {Iterable.<module:engine/view/element~Element>} children
* The child elements.
* @return {null|module:engine/view/element~Element}
* The preview child element if available.
*/
export function getPreviewContainer(children) {
// eslint-disable-next-line no-restricted-syntax
for (const child of children) {
if (child.hasAttribute('data-drupal-media-preview')) {
return child;
}
if (child.childCount) {
const recursive = getPreviewContainer(child.getChildren());
// Return only if preview container was found within this element's
// children.
if (recursive) {
return recursive;
}
}
}
return null;
}
/**
* Gets model attribute key based on Drupal Element Style group.
*
* @example
* Example: 'align' -> 'drupalElementStyleAlign'
*
* @param {string} group
* The name of the group (ex. 'align', 'viewMode').
* @return {string}
* Model attribute key.
*
* @internal
*/
export function groupNameToModelAttributeKey(group) {
// Manipulate string to have first letter capitalized to append in camel case.
const capitalizedFirst = group[0].toUpperCase() + group.substring(1);
return `drupalElementStyle${capitalizedFirst}`;
}

View File

@ -0,0 +1 @@
<svg width="20" height="20" 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