UNPKG

@ckeditor/ckeditor5-image

Version:

Image feature for CKEditor 5.

226 lines (225 loc) • 9.5 kB
/** * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /** * @module image/imagecaption/imagecaptionediting */ import { Plugin } from 'ckeditor5/src/core'; import { Element, enablePlaceholder } from 'ckeditor5/src/engine'; import { toWidgetEditable } from 'ckeditor5/src/widget'; import ToggleImageCaptionCommand from './toggleimagecaptioncommand'; import ImageUtils from '../imageutils'; import ImageCaptionUtils from './imagecaptionutils'; /** * The image caption engine plugin. It is responsible for: * * * registering converters for the caption element, * * registering converters for the caption model attribute, * * registering the {@link module:image/imagecaption/toggleimagecaptioncommand~ToggleImageCaptionCommand `toggleImageCaption`} command. */ export default class ImageCaptionEditing extends Plugin { /** * @inheritDoc */ static get requires() { return [ImageUtils, ImageCaptionUtils]; } /** * @inheritDoc */ static get pluginName() { return 'ImageCaptionEditing'; } /** * @inheritDoc */ constructor(editor) { super(editor); 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: 'imageBlock', allowContentOf: '$block', isLimit: true }); } else { schema.extend('caption', { allowIn: 'imageBlock' }); } editor.commands.add('toggleImageCaption', new ToggleImageCaptionCommand(this.editor)); this._setupConversion(); this._setupImageTypeCommandsIntegration(); this._registerCaptionReconversion(); } /** * Configures conversion pipelines to support upcasting and downcasting * image captions. */ _setupConversion() { const editor = this.editor; const view = editor.editing.view; const imageUtils = editor.plugins.get('ImageUtils'); const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils'); const t = editor.t; // View -> model converter for the data pipeline. editor.conversion.for('upcast').elementToElement({ view: element => imageCaptionUtils.matchImageCaptionViewElement(element), model: 'caption' }); // Model -> view converter for the data pipeline. editor.conversion.for('dataDowncast').elementToElement({ model: 'caption', view: (modelElement, { writer }) => { if (!imageUtils.isBlockImage(modelElement.parent)) { return null; } return writer.createContainerElement('figcaption'); } }); // Model -> view converter for the editing pipeline. editor.conversion.for('editingDowncast').elementToElement({ model: 'caption', view: (modelElement, { writer }) => { if (!imageUtils.isBlockImage(modelElement.parent)) { return null; } const figcaptionElement = writer.createEditableElement('figcaption'); writer.setCustomProperty('imageCaption', true, figcaptionElement); figcaptionElement.placeholder = t('Enter image caption'); enablePlaceholder({ view, element: figcaptionElement, keepOnFocus: true }); const imageAlt = modelElement.parent.getAttribute('alt'); const label = imageAlt ? t('Caption for image: %0', [imageAlt]) : t('Caption for the image'); return toWidgetEditable(figcaptionElement, writer, { label }); } }); } /** * Integrates with {@link module:image/image/imagetypecommand~ImageTypeCommand image type commands} * to make sure the caption is preserved when the type of an image changes so it can be restored * in the future if the user decides they want their caption back. */ _setupImageTypeCommandsIntegration() { const editor = this.editor; const imageUtils = editor.plugins.get('ImageUtils'); const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils'); const imageTypeInlineCommand = editor.commands.get('imageTypeInline'); const imageTypeBlockCommand = editor.commands.get('imageTypeBlock'); const handleImageTypeChange = evt => { // The image type command execution can be unsuccessful. if (!evt.return) { return; } const { oldElement, newElement } = evt.return; /* istanbul ignore if: paranoid check -- @preserve */ if (!oldElement) { return; } if (imageUtils.isBlockImage(oldElement)) { const oldCaptionElement = imageCaptionUtils.getCaptionFromImageModelElement(oldElement); // If the old element was a captioned block image (the caption was visible), // simply save it so it can be restored. if (oldCaptionElement) { this._saveCaption(newElement, oldCaptionElement); return; } } const savedOldElementCaption = this._getSavedCaption(oldElement); // If either: // // * the block image didn't have a visible caption, // * the block image caption was hidden (and already saved), // * the inline image was passed // // just try to "pass" the saved caption from the old image to the new image // so it can be retrieved in the future if the user wants it back. if (savedOldElementCaption) { // Note: Since we're writing to a WeakMap, we don't bother with removing the // [ oldElement, savedOldElementCaption ] pair from it. this._saveCaption(newElement, savedOldElementCaption); } }; // Presence of the commands depends on the Image(Inline|Block)Editing plugins loaded in the editor. if (imageTypeInlineCommand) { this.listenTo(imageTypeInlineCommand, 'execute', handleImageTypeChange, { priority: 'low' }); } if (imageTypeBlockCommand) { this.listenTo(imageTypeBlockCommand, 'execute', handleImageTypeChange, { priority: 'low' }); } } /** * Returns the saved {@link module:engine/model/element~Element#toJSON JSONified} caption * of an image model element. * * See {@link #_saveCaption}. * * @internal * @param imageModelElement The model element the caption should be returned for. * @returns The model caption element or `null` if there is none. */ _getSavedCaption(imageModelElement) { const jsonObject = this._savedCaptionsMap.get(imageModelElement); return jsonObject ? Element.fromJSON(jsonObject) : null; } /** * Saves a {@link module:engine/model/element~Element#toJSON JSONified} caption for * an image element to allow restoring it in the future. * * A caption is saved every time it gets hidden and/or the type of an image changes. The * user should be able to restore it on demand. * * **Note**: The caption cannot be stored in the image model element attribute because, * for instance, when the model state propagates to collaborators, the attribute would get * lost (mainly because it does not convert to anything when the caption is hidden) and * the states of collaborators' models would de-synchronize causing numerous issues. * * See {@link #_getSavedCaption}. * * @internal * @param imageModelElement The model element the caption is saved for. * @param caption The caption model element to be saved. */ _saveCaption(imageModelElement, caption) { this._savedCaptionsMap.set(imageModelElement, caption.toJSON()); } /** * Reconverts image caption when image alt attribute changes. * The change of alt attribute is reflected in caption's aria-label attribute. */ _registerCaptionReconversion() { const editor = this.editor; const model = editor.model; const imageUtils = editor.plugins.get('ImageUtils'); const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils'); model.document.on('change:data', () => { const changes = model.document.differ.getChanges(); for (const change of changes) { if (change.attributeKey !== 'alt') { continue; } const image = change.range.start.nodeAfter; if (imageUtils.isBlockImage(image)) { const caption = imageCaptionUtils.getCaptionFromImageModelElement(image); if (!caption) { return; } editor.editing.reconvertItem(caption); } } }); } }