Skip to content
Snippets Groups Projects
Commit c6d7b83f authored by Ben Mullins's avatar Ben Mullins
Browse files

Issue #3222757 by lauriii, Wim Leers, nod_, rachel_norfolk, mgifford,...

Issue #3222757 by lauriii, Wim Leers, nod_, rachel_norfolk, mgifford, itmaybejj, Luke.Leber, andrewmacpherson, ckrina, solideogloria: [drupalImage] Make image alt text required or strongly encouraged
parent 337a8731
No related branches found
No related tags found
37 merge requests!7471uncessary 5 files are moved from media-library folder to misc folder,!7452Issue #1797438. HTML5 validation is preventing form submit and not fully...,!54479.5.x SF update,!5014Issue #3071143: Table Render Array Example Is Incorrect,!4868Issue #1428520: Improve menu parent link selection,!4289Issue #1344552 by marcingy, Niklas Fiekas, Ravi.J, aleevas, Eduardo Morales...,!4114Issue #2707291: Disable body-level scrolling when a dialog is open as a modal,!3630Issue #2815301 by Chi, DanielVeza, kostyashupenko, smustgrave: Allow to create...,!3291Issue #3336463: Rewrite rules for gzipped CSS and JavaScript aggregates never match,!3143Issue #3313342: [PHP 8.1] Deprecated function: strpos(): Passing null to parameter #1 LayoutBuilderUiCacheContext.php on line 28,!3102Issue #3164428 by DonAtt, longwave, sahil.goyal, Anchal_gupta, alexpott: Use...,!2853#3274419 Makes BaseFieldOverride inherit the internal property from the base field.,!2719Issue #3110137: Remove Classy from core.,!2437Issue #3238257 by hooroomoo, Wim Leers: Fragment link pointing to <textarea>...,!2378Issue #2875033: Optimize joins and table selection in SQL entity query implementation,!2074Issue #2707689: NodeForm::actions() checks for delete access on new entities,!2062Issue #3246454: Add weekly granularity to views date sort,!1591Issue #3199697: Add JSON:API Translation experimental module,!1484Exposed filters get values from URL when Ajax is on,!1255Issue #3238922: Refactor (if feasible) uses of the jQuery serialize function to use vanillaJS,!1254Issue #3238915: Refactor (if feasible) uses of the jQuery ready function to use VanillaJS,!1162Issue #3100350: Unable to save '/' root path alias,!1073issue #3191727: Focus states on mobile second level navigation items fixed,!10223132456: Fix issue where views instances are emptied before an ajax request is complete,!957Added throwing of InvalidPluginDefinitionException from getDefinition().,!925Issue #2339235: Remove taxonomy hard dependency on node module,!877Issue #2708101: Default value for link text is not saved,!873Issue #2875228: Site install not using batch API service,!872Draft: Issue #3221319: Race condition when creating menu links and editing content deletes menu links,!844Resolve #3036010 "Updaters",!712Issue #2909128: Autocomplete intermittent on Chrome Android,!617Issue #3043725: Provide a Entity Handler for user cancelation,!579Issue #2230909: Simple decimals fail to pass validation,!560Move callback classRemove outside of the loop,!555Issue #3202493,!485Sets the autocomplete attribute for username/password input field on login form.,!30Issue #3182188: Updates composer usage to point at ./vendor/bin/composer
This commit is part of merge request !2074. Comments created here will be created in the context of that merge request.
Showing
with 1092 additions and 39 deletions
......@@ -454,7 +454,7 @@ ckeditor5_image:
- drupalImage.DrupalImage
config:
image:
toolbar: [imageTextAlternative]
toolbar: [drupalImageAlternativeText]
drupal:
label: Image
library: ckeditor5/drupal.ckeditor5.image
......
......@@ -54,7 +54,11 @@ drupal.ckeditor5.codeBlock:
drupal.ckeditor5.image:
js:
js/build/drupalImage.js: { minified: true }
css:
theme:
css/image.css: { }
dependencies:
- core/drupal
- core/ckeditor5
- core/ckeditor5.image
......
/* cspell:ignore switchbutton */
/* https://css-tricks.com/the-raven-technique-one-step-closer-to-container-queries */
.ck .image,
.ck .image-inline {
--base-size: 100%;
--breakpoint-wide: 400px;
--breakpoint-medium: 100px;
--is-wide: clamp(0px, var(--base-size) - var(--breakpoint-wide), 1px);
--is-medium: calc(clamp(0px, var(--base-size) - var(--breakpoint-medium), 1px) - var(--is-wide));
--is-small: calc(1px - (var(--is-medium) + var(--is-wide)));
}
.ck.ck-responsive-form.ck-text-alternative-form--with-decorative-toggle {
width: auto;
}
.ck.ck-responsive-form .ck.ck-text-alternative-form__decorative-toggle,
.ck.ck-responsive-form .ck.ck-text-alternative-form__decorative-toggle .ck-switchbutton {
width: 100%;
}
.ck .image,
.ck .image-inline {
position: relative;
}
.ck .image-alternative-text-missing-wrapper {
position: absolute;
right: 10px;
bottom: 10px;
overflow: hidden;
max-width:
calc(
(var(--is-small) * 0)
+ (var(--is-medium) * 33)
+ (var(--is-wide) * 999999)
);
border-left: calc((var(--is-small) * 0) + (var(--is-medium) * 3) + (var(--is-wide) * 3)) solid #ffd23f;
border-radius: 2px;
background: #232429;
font-size: 14px;
}
.ck figcaption ~ .image-alternative-text-missing-wrapper {
top: 10px;
bottom: auto;
}
.ck .image-alternative-text-missing-wrapper .ck.ck-button {
padding: 12px 12px 12px 8px;
cursor: pointer;
color: #fff;
background: none !important; /* Override background for all states. */
}
.ck .image-alternative-text-missing-wrapper .ck.ck-button:before {
width: 16px;
height: 16px;
padding-right: 8px;
content: "";
background: url("../icons/warning.svg") left center no-repeat;
}
.ck .image-alternative-text-missing-wrapper .ck.ck-button:after {
display: inline-block;
width: 12px;
height: 12px;
padding-left: 2rem;
content: "";
background: url("../icons/caret.svg") right center no-repeat;
font-size: 18px;
font-weight: bold;
}
.ck .image-alternative-text-missing-wrapper .ck-tooltip {
display: block;
overflow: visible;
}
.ck .image-alternative-text-missing-wrapper:hover .ck-tooltip {
visibility: visible;
opacity: 1;
}
.ck .image-alternative-text-missing-wrapper:hover .ck-tooltip__text {
display: block;
width: 240px;
}
<svg width="12" height="12" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.75 10.5 8.25 6l-4.5-4.5" stroke="#FFD23F" stroke-width="2"/></svg>
<svg fill="none" height="16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#ffd23f"><path d="M6.5 1h3v9h-3z"/><circle cx="8" cy="13.5" r="1.5"/></g></svg>
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:words drupalimageediting */
/* cspell:words drupalimageediting drupalimagealternativetext */
import { Plugin } from 'ckeditor5/src/core';
import DrupalImageEditing from './drupalimageediting';
import DrupalImageAlternativeText from './drupalimagealternativetext';
/**
* @internal
......@@ -12,7 +13,7 @@ class DrupalImage extends Plugin {
* @inheritdoc
*/
static get requires() {
return [DrupalImageEditing];
return [DrupalImageEditing, DrupalImageAlternativeText];
}
/**
......
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:words imagealternativetext imagetextalternativeediting drupalimagealternativetextediting drupalimagealternativetextui */
/**
* @module drupalImage/imagealternativetext
*/
import { Plugin } from 'ckeditor5/src/core';
import DrupalImageAlternativeTextEditing from './imagealternativetext/drupalimagealternativetextediting';
import DrupalImageAlternativeTextUi from './imagealternativetext/drupalimagealternativetextui';
/**
* The Drupal-specific image text alternative plugin.
*
* This has been implemented based on the CKEditor 5 built in image alternative
* text plugin. This plugin enhances the original upstream form with a toggle
* button that allows users to explicitly mark images as decorative, which is
* downcast to an empty `alt` attribute. This plugin also provides a warning for
* images that are missing the `alt` attribute, to ensure content authors don't
* leave the alternative text blank by accident.
*
* @see module:image/imagetextalternative~ImageTextAlternative
*
* @extends module:core/plugin~Plugin
*/
export default class DrupalImageAlternativeText extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [DrupalImageAlternativeTextEditing, DrupalImageAlternativeTextUi];
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'DrupalImageAlternativeText';
}
}
......@@ -471,6 +471,7 @@ export default class DrupalImageEditing extends Plugin {
allowAttributes: [
'dataEntityUuid',
'dataEntityType',
'isDecorative',
'width',
'height',
],
......@@ -482,6 +483,7 @@ export default class DrupalImageEditing extends Plugin {
allowAttributes: [
'dataEntityUuid',
'dataEntityType',
'isDecorative',
'width',
'height',
],
......
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:words imagealternativetext drupalimagealternativetextediting drupalimagetextalternativecommand textalternativemissingview imagetextalternativecommand */
/**
* @module drupalImage/imagealternativetext/drupalimagealternativetextediting
*/
import { Plugin } from 'ckeditor5/src/core';
import ImageTextAlternativeCommand from '@ckeditor/ckeditor5-image/src/imagetextalternative/imagetextalternativecommand';
/**
* The Drupal image alternative text editing plugin.
*
* Registers the `imageTextAlternative` command.
*
* @extends module:core/plugin~Plugin
*
* @internal
*/
export default class DrupalImageTextAlternativeEditing extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return ['ImageUtils'];
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'DrupalImageAlternativeTextEditing';
}
constructor(editor) {
super(editor);
/**
* Keeps references to instances of `TextAlternativeMissingView`.
*
* @member {Set<module:drupalImage/imagetextalternative/ui/textalternativemissingview~TextAlternativeMissingView>} #_missingAltTextViewReferences
* @private
*/
this._missingAltTextViewReferences = new Set();
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
editor.conversion
.for('editingDowncast')
.add(this._imageEditingDowncastConverter('attribute:alt', editor))
// Including changes to src ensures the converter will execute for images
// that do not yet have alt attributes, as we specifically want to add the
// missing alt text warning to images without alt attributes.
.add(this._imageEditingDowncastConverter('attribute:src', editor));
editor.commands.add(
'imageTextAlternative',
new ImageTextAlternativeCommand(this.editor),
);
editor.editing.view.on('render', () => {
// eslint-disable-next-line no-restricted-syntax
for (const view of this._missingAltTextViewReferences) {
// Destroy view instances that are not connected to the DOM to ensure
// there are no memory leaks.
// https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected
if (!view.button.element.isConnected) {
view.destroy();
this._missingAltTextViewReferences.delete(view);
}
}
});
}
/**
* Helper that generates model to editing view converters to display missing
* alt text warning.
*
* @param {string} eventName
* The name of the event the converter should be attached to.
*
* @return {function}
* A function that attaches downcast converter to the conversion dispatcher.
*
* @private
*/
_imageEditingDowncastConverter(eventName) {
const converter = (evt, data, conversionApi) => {
const editor = this.editor;
const imageUtils = editor.plugins.get('ImageUtils');
if (!imageUtils.isImage(data.item)) {
return;
}
const viewElement = conversionApi.mapper.toViewElement(data.item);
const existingWarning = Array.from(viewElement.getChildren()).find(
(child) => child.getCustomProperty('drupalImageMissingAltWarning'),
);
const hasAlt = data.item.hasAttribute('alt');
if (hasAlt) {
// Remove existing warning if alt text is set and there's an existing
// warning.
if (existingWarning) {
conversionApi.writer.remove(existingWarning);
}
return;
}
// Nothing to do if alt text doesn't exist and there's already an existing
// warning.
if (existingWarning) {
return;
}
const view = editor.ui.componentFactory.create(
'drupalImageAlternativeTextMissing',
);
view.listenTo(editor.ui, 'update', () => {
const selectionRange = editor.model.document.selection.getFirstRange();
const imageRange = editor.model.createRangeOn(data.item);
// Set the view `isSelected` property depending on whether the model
// element associated to the view element is in the selection.
view.set({
isSelected:
selectionRange.containsRange(imageRange) ||
selectionRange.isIntersecting(imageRange),
});
});
view.render();
// Add reference to the created view element so that it can be destroyed
// when the view is no longer connected.
this._missingAltTextViewReferences.add(view);
const html = conversionApi.writer.createUIElement(
'span',
{
class: 'image-alternative-text-missing-wrapper',
},
function (domDocument) {
const wrapperDomElement = this.toDomElement(domDocument);
wrapperDomElement.appendChild(view.element);
return wrapperDomElement;
},
);
conversionApi.writer.setCustomProperty(
'drupalImageMissingAltWarning',
true,
html,
);
conversionApi.writer.insert(
conversionApi.writer.createPositionAt(viewElement, 'end'),
html,
);
};
return (dispatcher) => {
dispatcher.on(eventName, converter, { priority: 'low' });
};
}
}
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:words drupalimagealternativetextui contextualballoon componentfactory imagealternativetextformview missingalternativetextview imagetextalternativeui imagealternativetext */
/**
* @module drupalImage/imagealternativetext/drupalimagealternativetextui
*/
import { Plugin, icons } from 'ckeditor5/src/core';
import {
ButtonView,
ContextualBalloon,
clickOutsideHandler,
} from 'ckeditor5/src/ui';
import {
repositionContextualBalloon,
getBalloonPositionData,
} from '@ckeditor/ckeditor5-image/src/image/ui/utils';
import ImageAlternativeTextFormView from './ui/imagealternativetextformview';
import MissingAlternativeTextView from './ui/missingalternativetextview';
/**
* The Drupal-specific image alternative text UI plugin.
*
* This plugin is based on a version of the upstream alternative text UI plugin.
* This override enhances the UI with a new form element which allows marking
* images explicitly as decorative. This plugin also provides a UI component
* that can be displayed on images that are missing alternative text.
*
* The logic related to visibility, positioning, and keystrokes are unchanged
* from the upstream implementation.
*
* The plugin uses the contextual balloon.
*
* @see module:image/imagetextalternative/imagetextalternativeui~ImageTextAlternativeUI
* @see module:ui/panel/balloon/contextualballoon~ContextualBalloon
*
* @extends module:core/plugin~Plugin
*
* @internal
*/
export default class DrupalImageAlternativeTextUi extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ContextualBalloon];
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'DrupalImageTextAlternativeUI';
}
/**
* @inheritDoc
*/
init() {
this._createButton();
this._createForm();
this._createMissingAltTextComponent();
if (this.editor.plugins.has('ImageUploadEditing')) {
const imageUploadEditing = this.editor.plugins.get('ImageUploadEditing');
const imageUtils = this.editor.plugins.get('ImageUtils');
imageUploadEditing.on('uploadComplete', () => {
// Show form after upload if there's image widget in the current
// selection.
if (
imageUtils.getClosestSelectedImageWidget(
this.editor.editing.view.document.selection,
)
) {
this._showForm();
}
});
}
}
/**
* Creates a missing alt text view which can be displayed within image widgets
* where the image is missing alt text.
*
* The component is registered in the editor component factory.
*
* @see module:ui/componentfactory~ComponentFactory
*
* @private
*/
_createMissingAltTextComponent() {
this.editor.ui.componentFactory.add(
'drupalImageAlternativeTextMissing',
(locale) => {
const view = new MissingAlternativeTextView(locale);
view.listenTo(view.button, 'execute', () => {
// If the form is already in the balloon, it needs to be removed to
// avoid having multiple instances of the form in the balloon. This
// happens only in the edge case where this event is executed while
// the form is still in the balloon.
if (this._isInBalloon) {
this._balloon.remove(this._form);
}
this._showForm();
});
view.listenTo(this.editor.ui, 'update', () => {
view.set({ isVisible: !this._isVisible || !view.isSelected });
});
return view;
},
);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
// Destroy created UI components as they are not automatically destroyed
// @see https://github.com/ckeditor/ckeditor5/issues/1341
this._form.destroy();
}
/**
* Creates a button showing the balloon panel for changing the image text
* alternative and registers it in the editor component factory.
*
* @see module:ui/componentfactory~ComponentFactory
*
* @private
*/
_createButton() {
const editor = this.editor;
editor.ui.componentFactory.add('drupalImageAlternativeText', (locale) => {
const command = editor.commands.get('imageTextAlternative');
const view = new ButtonView(locale);
view.set({
label: Drupal.t('Change image alternative text'),
icon: icons.lowVision,
tooltip: true,
});
view.bind('isEnabled').to(command, 'isEnabled');
this.listenTo(view, 'execute', () => {
this._showForm();
});
return view;
});
}
/**
* Creates the text alternative form view.
*
* @private
*/
_createForm() {
const editor = this.editor;
const view = editor.editing.view;
const viewDocument = view.document;
const imageUtils = editor.plugins.get('ImageUtils');
/**
* The contextual balloon plugin instance.
*
* @private
* @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
*/
this._balloon = this.editor.plugins.get('ContextualBalloon');
/**
* A form used for changing the `alt` text value.
*
* @member {module:drupalImage/imagetextalternative/ui/imagealternativetextformview~ImageAlternativeTextFormView}
*/
this._form = new ImageAlternativeTextFormView(editor.locale);
// Render the form so its #element is available for clickOutsideHandler.
this._form.render();
this.listenTo(this._form, 'submit', () => {
editor.execute('imageTextAlternative', {
newValue: this._form.decorativeToggle.isOn
? ''
: this._form.labeledInput.fieldView.element.value,
});
this._hideForm(true);
});
this.listenTo(this._form, 'cancel', () => {
this._hideForm(true);
});
// Reposition the toolbar when the decorative toggle is executed because
// it has an impact on the form size.
this.listenTo(this._form.decorativeToggle, 'execute', () => {
repositionContextualBalloon(editor);
});
// Close the form on Esc key press.
this._form.keystrokes.set('Esc', (data, cancel) => {
this._hideForm(true);
cancel();
});
// Reposition the balloon or hide the form if an image widget is no longer
// selected.
this.listenTo(editor.ui, 'update', () => {
if (!imageUtils.getClosestSelectedImageWidget(viewDocument.selection)) {
this._hideForm(true);
} else if (this._isVisible) {
repositionContextualBalloon(editor);
}
});
// Close on click outside of balloon panel element.
clickOutsideHandler({
emitter: this._form,
activator: () => this._isVisible,
contextElements: [this._balloon.view.element],
callback: () => this._hideForm(),
});
}
/**
* Shows the form in the balloon.
*
* @private
*/
_showForm() {
if (this._isVisible) {
return;
}
const editor = this.editor;
const command = editor.commands.get('imageTextAlternative');
const decorativeToggle = this._form.decorativeToggle;
const labeledInput = this._form.labeledInput;
this._form.disableCssTransitions();
if (!this._isInBalloon) {
this._balloon.add({
view: this._form,
position: getBalloonPositionData(editor),
});
}
decorativeToggle.isOn = command.value === '';
// Make sure that each time the panel shows up, the field remains in sync
// with the value of the command. If the user typed in the input, then
// canceled the balloon (`labeledInput#value` stays unaltered) and re-opened
// it without changing the value of the command, they would see the old
// value instead of the actual value of the command.
// https://github.com/ckeditor/ckeditor5-image/issues/114
labeledInput.fieldView.element.value = command.value || '';
labeledInput.fieldView.value = labeledInput.fieldView.element.value;
if (!decorativeToggle.isOn) {
labeledInput.fieldView.select();
} else {
decorativeToggle.focus();
}
this._form.enableCssTransitions();
}
/**
* Removes the form from the balloon.
*
* @param {Boolean} [focusEditable=false]
* Controls whether the editing view is focused afterwards.
*
* @private
*/
_hideForm(focusEditable) {
if (!this._isInBalloon) {
return;
}
// Blur the input element before removing it from DOM to prevent issues in
// some browsers.
// See https://github.com/ckeditor/ckeditor5/issues/1501.
if (this._form.focusTracker.isFocused) {
this._form.saveButtonView.focus();
}
this._balloon.remove(this._form);
if (focusEditable) {
this.editor.editing.view.focus();
}
}
/**
* Returns `true` when the form is the visible view in the balloon.
*
* @type {Boolean}
*
* @private
*/
get _isVisible() {
return this._balloon.visibleView === this._form;
}
/**
* Returns `true` when the form is in the balloon.
*
* @type {Boolean}
*
* @private
*/
get _isInBalloon() {
return this._balloon.hasView(this._form);
}
}
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:words focustracker keystrokehandler labeledfield labeledfieldview buttonview viewcollection focusables focuscycler switchbuttonview imagealternativetextformview imagealternativetext */
/**
* @module drupalImage/imagealternativetext/ui/imagealternativetextformview
*/
import {
ButtonView,
FocusCycler,
LabeledFieldView,
SwitchButtonView,
View,
ViewCollection,
createLabeledInputText,
injectCssTransitionDisabler,
submitHandler,
} from 'ckeditor5/src/ui';
import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils';
import { icons } from 'ckeditor5/src/core';
/**
* A class rendering alternative text form view.
*
* @extends module:ui/view~View
*
* @internal
*/
export default class ImageAlternativeTextFormView extends View {
/**
* @inheritDoc
*/
constructor(locale) {
super(locale);
/**
* Tracks information about the DOM focus in the form.
*
* @readonly
* @member {module:utils/focustracker~FocusTracker}
*/
this.focusTracker = new FocusTracker();
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*
* @readonly
* @member {module:utils/keystrokehandler~KeystrokeHandler}
*/
this.keystrokes = new KeystrokeHandler();
/**
* A toggle for marking the image as decorative.
*
* @member {module:ui/button/switchbuttonview~SwitchButtonView} #decorativeToggle
*/
this.decorativeToggle = this._decorativeToggleView();
/**
* An input with a label.
*
* @member {module:ui/labeledfield/labeledfieldview~LabeledFieldView} #labeledInput
*/
this.labeledInput = this._createLabeledInputView();
/**
* A button used to submit the form.
*
* @member {module:ui/button/buttonview~ButtonView} #saveButtonView
*/
this.saveButtonView = this._createButton(
Drupal.t('Save'),
icons.check,
'ck-button-save',
);
this.saveButtonView.type = 'submit';
// Save button is disabled when image is not decorative and alt text is
// empty.
this.saveButtonView
.bind('isEnabled')
.to(
this.decorativeToggle,
'isOn',
this.labeledInput,
'isEmpty',
(isDecorativeToggleOn, isLabeledInputEmpty) =>
isDecorativeToggleOn || !isLabeledInputEmpty,
);
/**
* A button used to cancel the form.
*
* @member {module:ui/button/buttonview~ButtonView} #cancelButtonView
*/
this.cancelButtonView = this._createButton(
Drupal.t('Cancel'),
icons.cancel,
'ck-button-cancel',
'cancel',
);
/**
* A collection of views which can be focused in the form.
*
* @member {module:ui/viewcollection~ViewCollection}
*
* @readonly
* @protected
*/
this._focusables = new ViewCollection();
/**
* Helps cycling over focusables in the form.
*
* @member {module:ui/focuscycler~FocusCycler}
*
* @readonly
* @protected
*/
this._focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
// Navigate form fields backwards using the Shift + Tab keystroke.
focusPrevious: 'shift + tab',
// Navigate form fields forwards using the Tab key.
focusNext: 'tab',
},
});
this.setTemplate({
tag: 'form',
attributes: {
class: [
'ck',
'ck-text-alternative-form',
'ck-text-alternative-form--with-decorative-toggle',
'ck-responsive-form',
],
// https://github.com/ckeditor/ckeditor5-image/issues/40
tabindex: '-1',
},
children: [
{
tag: 'div',
attributes: {
class: ['ck', 'ck-text-alternative-form__decorative-toggle'],
},
children: [this.decorativeToggle],
},
this.labeledInput,
this.saveButtonView,
this.cancelButtonView,
],
});
injectCssTransitionDisabler(this);
}
/**
* @inheritDoc
*/
render() {
super.render();
this.keystrokes.listenTo(this.element);
submitHandler({ view: this });
[
this.decorativeToggle,
this.labeledInput,
this.saveButtonView,
this.cancelButtonView,
].forEach((v) => {
// Register the view as focusable.
this._focusables.add(v);
// Register the view in the focus tracker.
this.focusTracker.add(v.element);
});
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Creates the button view.
*
* @param {String} label
* The button label
* @param {String} icon
* The button's icon.
* @param {String} className
* The additional button CSS class name.
* @param {String} [eventName]
* The event name that the ButtonView#execute event will be delegated to.
* @returns {module:ui/button/buttonview~ButtonView}
* The button view instance.
*
* @private
*/
_createButton(label, icon, className, eventName) {
const button = new ButtonView(this.locale);
button.set({
label,
icon,
tooltip: true,
});
button.extendTemplate({
attributes: {
class: className,
},
});
if (eventName) {
button.delegate('execute').to(this, eventName);
}
return button;
}
/**
* Creates an input with a label.
*
* @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView}
* Labeled field view instance.
*
* @private
*/
_createLabeledInputView() {
const labeledInput = new LabeledFieldView(
this.locale,
createLabeledInputText,
);
labeledInput
.bind('class')
.to(this.decorativeToggle, 'isOn', (value) => (value ? 'ck-hidden' : ''));
labeledInput.label = Drupal.t('Text alternative');
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;
}
}
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:words 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],
});
}
}
......@@ -8,6 +8,7 @@
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\node\Entity\Node;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\RoleInterface;
use Symfony\Component\Validator\ConstraintViolation;
......@@ -23,6 +24,7 @@
class CKEditor5Test extends CKEditor5TestBase {
use TestFileCreationTrait;
use CKEditor5TestTrait;
/**
* {@inheritdoc}
......@@ -102,22 +104,22 @@ function (ConstraintViolation $v) {
));
$this->drupalGet('node/add');
$this->waitForEditor();
$page->fillField('title[0][value]', 'My test content');
// Ensure that CKEditor 5 is focused.
$this->click('.ck-content');
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image = $this->getTestFiles('image')[0];
$image_upload_field->attachFile($this->container->get('file_system')->realpath($image->uri));
$assert_session->waitForElementVisible('css', '.ck-widget.image');
$this->click('.ck-widget.image');
$balloon_panel = $page->find('css', '.ck-balloon-panel');
$balloon_buttons = $balloon_panel->findAll('css', '[aria-label="Image toolbar"] button');
$this->assertSame('Change image text alternative', $balloon_buttons[0]->find('css', '.ck-button__label')->getHtml());
$balloon_buttons[0]->click();
$assert_session->waitForElementVisible('css', '.ck-balloon-panel .ck-text-alternative-form');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-balloon-panel .ck-text-alternative-form'));
$alt_override_input = $page->find('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]');
$this->assertSame('', $alt_override_input->getValue());
$alt_override_input->setValue('</em> Kittens & llamas are cute');
$balloon_panel->pressButton('Save');
$this->getBalloonButton('Save')->click();
$page->pressButton('Save');
$uploaded_image = File::load(1);
......@@ -375,6 +377,10 @@ public function testEditorFileReferenceIntegration() {
$this->drupalGet('node/add');
$page->fillField('title[0][value]', 'My test content');
// Ensure that CKEditor 5 is focused.
$this->click('.ck-content');
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image = $this->getTestFiles('image')[0];
$image_upload_field->attachFile($this->container->get('file_system')->realpath($image->uri));
......@@ -382,6 +388,13 @@ public function testEditorFileReferenceIntegration() {
// upload has completed and the image has been downcast.
// @see https://www.drupal.org/project/drupal/issues/3250587
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-content img[data-entity-uuid]'));
// Add alt text to the image.
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.image.ck-widget > img'));
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-balloon-panel .ck-text-alternative-form'));
$alt_override_input = $page->find('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]');
$alt_override_input->setValue('There is now alt text');
$this->getBalloonButton('Save')->click();
$page->pressButton('Save');
$uploaded_image = File::load(1);
......@@ -391,7 +404,7 @@ public function testEditorFileReferenceIntegration() {
// Ensure that width, height, and length attributes are not stored in the
// database.
$this->assertEquals(sprintf('<img data-entity-uuid="%s" data-entity-type="file" src="%s">', $image_uuid, $image_url), Node::load(1)->get('body')->value);
$this->assertEquals(sprintf('<img data-entity-uuid="%s" data-entity-type="file" src="%s" alt="There is now alt text">', $image_uuid, $image_url), Node::load(1)->get('body')->value);
// Ensure that data-entity-uuid and data-entity-type attributes are upcasted
// correctly to CKEditor model.
......
......@@ -409,6 +409,108 @@ public function testLinkability(string $image_type, bool $unrestricted) {
}
}
/**
* Tests that alt text is required for images.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion
*
* @dataProvider providerAltTextRequired
*/
public function testAltTextRequired(bool $unrestricted) {
// Disable filter_html.
if ($unrestricted) {
FilterFormat::load('test_format')
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
}
// Make the test content has a block image and an inline image.
$img_tag = '<img data-entity-type="file" data-entity-uuid="' . $this->file->uuid() . '" src="' . $this->file->createFileUrl() . '" width="500" />';
$this->host->body->value .= $img_tag . "<p>$img_tag</p>";
$this->host->save();
$page = $this->getSession()->getPage();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
// Confirm both of the images exist.
$this->assertNotEmpty($image_block = $assert_session->waitForElementVisible('css', ".ck-content .ck-widget.image"));
$this->assertNotEmpty($image_inline = $assert_session->waitForElementVisible('css', ".ck-content .ck-widget.image-inline"));
// Confirm both of the images have an alt text required warning.
$this->assertNotEmpty($image_block->find('css', '.image-alternative-text-missing-wrapper'));
$this->assertNotEmpty($image_inline->find('css', '.image-alternative-text-missing-wrapper'));
// Add alt text to the block image.
$image_block->find('css', '.image-alternative-text-missing button')->click();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-balloon-panel'));
$this->assertVisibleBalloon('.ck-text-alternative-form');
// Ensure that the missing alt text warning is hidden when the alternative
// text form is open.
$assert_session->waitForElement('css', '.ck-content .ck-widget.image .image-alternative-text-missing.ck-hidden');
$assert_session->elementExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing');
$assert_session->elementNotExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing.ck-hidden');
// Ensure that the missing alt text error is not added to decorative images.
$this->assertNotEmpty($decorative_button = $this->getBalloonButton('Decorative image'));
$assert_session->elementExists('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]');
$decorative_button->click();
$assert_session->elementExists('css', '.ck-content .ck-widget.image .image-alternative-text-missing.ck-hidden');
$assert_session->elementExists('css', ".ck-content .ck-widget.image-inline .image-alternative-text-missing-wrapper");
$assert_session->elementNotExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing.ck-hidden');
// Ensure that the missing alt text error is removed after saving the
// changes.
$this->assertNotEmpty($save_button = $this->getBalloonButton('Save'));
$save_button->click();
$this->assertTrue($assert_session->waitForElementRemoved('css', ".ck-content .ck-widget.image .image-alternative-text-missing-wrapper"));
$assert_session->elementExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing-wrapper');
// Ensure that the decorative image downcasts into empty alt attribute.
$editor_dom = $this->getEditorDataAsDom();
$decorative_img = $editor_dom->getElementsByTagName('img')->item(0);
$this->assertTrue($decorative_img->hasAttribute('alt'));
$this->assertEmpty($decorative_img->getAttribute('alt'));
// Ensure that missing alt text error is not added to images with alt text.
$this->assertNotEmpty($alt_text_button = $this->getBalloonButton('Change image alternative text'));
$alt_text_button->click();
$decorative_button->click();
$this->assertNotEmpty($save_button = $this->getBalloonButton('Save'));
$this->assertTrue($save_button->hasClass('ck-disabled'));
$this->assertNotEmpty($alt_override_input = $page->find('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]'));
$alt_override_input->setValue('There is now alt text');
$this->assertTrue($assert_session->waitForElementRemoved('css', '.ck-balloon-panel .ck-text-alternative-form .ck-disabled'));
$this->assertFalse($save_button->hasClass('ck-disabled'));
$save_button->click();
// Save the node and confirm that the alt text is retained.
$page->pressButton('Save');
$this->assertNotEmpty($assert_session->waitForElement('css', 'img[alt="There is now alt text"]'));
// Ensure that alt form is opened after image upload.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image = $this->getTestFiles('image')[0];
$image_upload_field->attachFile($this->container->get('file_system')->realpath($image->uri));
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-widget.image'));
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-balloon-panel'));
$this->assertVisibleBalloon('.ck-text-alternative-form');
}
public function providerAltTextRequired(): array {
return [
'Restricted' => [FALSE],
'Unrestricted' => [TRUE],
];
}
public function providerLinkability(): array {
return [
'BLOCK image, restricted' => ['block', FALSE],
......@@ -488,10 +590,10 @@ public function providerAlignment() {
}
/**
* Checks that width attribute is correct after upcasting, then downcasting.
* Ensures that width attribute upcasts and downcasts correctly.
*
* @param string $width
* The width input for source editing.
* The width input for the image.
*
* @dataProvider providerWidth
*/
......@@ -499,35 +601,26 @@ public function testWidth(string $width): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('node/add');
$page->fillField('title[0][value]', 'My test content');
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image = $this->getTestFiles('image')[0];
$image_upload_field->attachFile($this->container->get('file_system')->realpath($image->uri));
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'figure.image'));
// Add image to the host body.
$this->host->body->value = sprintf('<img data-foo="bar" alt="drupalimage test image" data-entity-type="file" data-entity-uuid="%s" src="%s" width="%s" />', $this->file->uuid(), $this->file->createFileUrl(), $width);
$this->host->save();
// Edit the source of the image through the UI.
$page->pressButton('Source');
// Get editor data.
$editor_data = $this->getEditorDataAsDom();
// Get the image element data from the editor then set the new width.
$image = $editor_data->getElementsByTagName('img')->item(0);
$image->setAttribute('width', $width);
$new_html = $image->C14N();
$text_area = $page->find('css', '.ck-source-editing-area > textarea');
// Set the value of the source code to the updated HTML that has the width
// attribute.
$text_area->setValue($new_html);
// Toggle source editing to force upcasting.
$page->pressButton('Source');
$assert_session->waitForElementVisible('css', 'img');
// Toggle source editing to force downcasting.
$page->pressButton('Source');
// Get editor data.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Ensure that the image is upcast as expected. In the editing view, the
// width attribute should downcast to an inline style on the container
// element.
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-widget.image[style] img'));
// Ensure that the width attribute is retained on downcast.
$editor_data = $this->getEditorDataAsDom();
$width_from_editor = $editor_data->getElementsByTagName('img')->item(0)->getAttribute('width');
// Check the contents of the source editing area.
$this->assertSame($width, $width_from_editor);
// Save the node and ensure that the width attribute is retained.
$page->pressButton('Save');
$this->assertNotEmpty($assert_session->waitForElement('css', "img[width='$width']"));
}
/**
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment