1 | /**
|
2 | * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
|
3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
|
4 | */
|
5 | /**
|
6 | * @module link/linkimageediting
|
7 | */
|
8 | import { Plugin } from 'ckeditor5/src/core';
|
9 | import { Matcher } from 'ckeditor5/src/engine';
|
10 | import { toMap } from 'ckeditor5/src/utils';
|
11 | import LinkEditing from './linkediting';
|
12 | /**
|
13 | * The link image engine feature.
|
14 | *
|
15 | * It accepts the `linkHref="url"` attribute in the model for the {@link module:image/image~Image `<imageBlock>`} element
|
16 | * which allows linking images.
|
17 | */
|
18 | export default class LinkImageEditing extends Plugin {
|
19 | /**
|
20 | * @inheritDoc
|
21 | */
|
22 | static get requires() {
|
23 | return ['ImageEditing', 'ImageUtils', LinkEditing];
|
24 | }
|
25 | /**
|
26 | * @inheritDoc
|
27 | */
|
28 | static get pluginName() {
|
29 | return 'LinkImageEditing';
|
30 | }
|
31 | /**
|
32 | * @inheritDoc
|
33 | */
|
34 | init() {
|
35 | const editor = this.editor;
|
36 | const schema = editor.model.schema;
|
37 | if (editor.plugins.has('ImageBlockEditing')) {
|
38 | schema.extend('imageBlock', { allowAttributes: ['linkHref'] });
|
39 | }
|
40 | editor.conversion.for('upcast').add(upcastLink(editor));
|
41 | editor.conversion.for('downcast').add(downcastImageLink(editor));
|
42 | // Definitions for decorators are provided by the `link` command and the `LinkEditing` plugin.
|
43 | this._enableAutomaticDecorators();
|
44 | this._enableManualDecorators();
|
45 | }
|
46 | /**
|
47 | * Processes {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition automatic decorators} definitions and
|
48 | * attaches proper converters that will work when linking an image.`
|
49 | */
|
50 | _enableAutomaticDecorators() {
|
51 | const editor = this.editor;
|
52 | const command = editor.commands.get('link');
|
53 | const automaticDecorators = command.automaticDecorators;
|
54 | if (automaticDecorators.length) {
|
55 | editor.conversion.for('downcast').add(automaticDecorators.getDispatcherForLinkedImage());
|
56 | }
|
57 | }
|
58 | /**
|
59 | * Processes transformed {@link module:link/utils/manualdecorator~ManualDecorator} instances and attaches proper converters
|
60 | * that will work when linking an image.
|
61 | */
|
62 | _enableManualDecorators() {
|
63 | const editor = this.editor;
|
64 | const command = editor.commands.get('link');
|
65 | for (const decorator of command.manualDecorators) {
|
66 | if (editor.plugins.has('ImageBlockEditing')) {
|
67 | editor.model.schema.extend('imageBlock', { allowAttributes: decorator.id });
|
68 | }
|
69 | if (editor.plugins.has('ImageInlineEditing')) {
|
70 | editor.model.schema.extend('imageInline', { allowAttributes: decorator.id });
|
71 | }
|
72 | editor.conversion.for('downcast').add(downcastImageLinkManualDecorator(decorator));
|
73 | editor.conversion.for('upcast').add(upcastImageLinkManualDecorator(editor, decorator));
|
74 | }
|
75 | }
|
76 | }
|
77 | /**
|
78 | * Returns a converter for linked block images that consumes the "href" attribute
|
79 | * if a link contains an image.
|
80 | *
|
81 | * @param editor The editor instance.
|
82 | */
|
83 | function upcastLink(editor) {
|
84 | const isImageInlinePluginLoaded = editor.plugins.has('ImageInlineEditing');
|
85 | const imageUtils = editor.plugins.get('ImageUtils');
|
86 | return dispatcher => {
|
87 | dispatcher.on('element:a', (evt, data, conversionApi) => {
|
88 | const viewLink = data.viewItem;
|
89 | const imageInLink = imageUtils.findViewImgElement(viewLink);
|
90 | if (!imageInLink) {
|
91 | return;
|
92 | }
|
93 | const blockImageView = imageInLink.findAncestor(element => imageUtils.isBlockImageView(element));
|
94 | // There are four possible cases to consider here
|
95 | //
|
96 | // 1. A "root > ... > figure.image > a > img" structure.
|
97 | // 2. A "root > ... > figure.image > a > picture > img" structure.
|
98 | // 3. A "root > ... > block > a > img" structure.
|
99 | // 4. A "root > ... > block > a > picture > img" structure.
|
100 | //
|
101 | // but the last 2 cases should only be considered by this converter when the inline image plugin
|
102 | // is NOT loaded in the editor (because otherwise, that would be a plain, linked inline image).
|
103 | if (isImageInlinePluginLoaded && !blockImageView) {
|
104 | return;
|
105 | }
|
106 | // There's an image inside an <a> element - we consume it so it won't be picked up by the Link plugin.
|
107 | const consumableAttributes = { attributes: ['href'] };
|
108 | // Consume the `href` attribute so the default one will not convert it to $text attribute.
|
109 | if (!conversionApi.consumable.consume(viewLink, consumableAttributes)) {
|
110 | // Might be consumed by something else - i.e. other converter with priority=highest - a standard check.
|
111 | return;
|
112 | }
|
113 | const linkHref = viewLink.getAttribute('href');
|
114 | // Missing the 'href' attribute.
|
115 | if (!linkHref) {
|
116 | return;
|
117 | }
|
118 | // A full definition of the image feature.
|
119 | // figure > a > img: parent of the view link element is an image element (figure).
|
120 | let modelElement = data.modelCursor.parent;
|
121 | if (!modelElement.is('element', 'imageBlock')) {
|
122 | // a > img: parent of the view link is not the image (figure) element. We need to convert it manually.
|
123 | const conversionResult = conversionApi.convertItem(imageInLink, data.modelCursor);
|
124 | // Set image range as conversion result.
|
125 | data.modelRange = conversionResult.modelRange;
|
126 | // Continue conversion where image conversion ends.
|
127 | data.modelCursor = conversionResult.modelCursor;
|
128 | modelElement = data.modelCursor.nodeBefore;
|
129 | }
|
130 | if (modelElement && modelElement.is('element', 'imageBlock')) {
|
131 | // Set the linkHref attribute from link element on model image element.
|
132 | conversionApi.writer.setAttribute('linkHref', linkHref, modelElement);
|
133 | }
|
134 | }, { priority: 'high' });
|
135 | // Using the same priority that `upcastImageLinkManualDecorator()` converter guarantees
|
136 | // that manual decorators will decorate the proper element.
|
137 | };
|
138 | }
|
139 | /**
|
140 | * Creates a converter that adds `<a>` to linked block image view elements.
|
141 | */
|
142 | function downcastImageLink(editor) {
|
143 | const imageUtils = editor.plugins.get('ImageUtils');
|
144 | return dispatcher => {
|
145 | dispatcher.on('attribute:linkHref:imageBlock', (evt, data, conversionApi) => {
|
146 | if (!conversionApi.consumable.consume(data.item, evt.name)) {
|
147 | return;
|
148 | }
|
149 | // The image will be already converted - so it will be present in the view.
|
150 | const viewFigure = conversionApi.mapper.toViewElement(data.item);
|
151 | const writer = conversionApi.writer;
|
152 | // But we need to check whether the link element exists.
|
153 | const linkInImage = Array.from(viewFigure.getChildren())
|
154 | .find((child) => child.is('element', 'a'));
|
155 | const viewImage = imageUtils.findViewImgElement(viewFigure);
|
156 | // <picture>...<img/></picture> or <img/>
|
157 | const viewImgOrPicture = viewImage.parent.is('element', 'picture') ? viewImage.parent : viewImage;
|
158 | // If so, update the attribute if it's defined or remove the entire link if the attribute is empty.
|
159 | if (linkInImage) {
|
160 | if (data.attributeNewValue) {
|
161 | writer.setAttribute('href', data.attributeNewValue, linkInImage);
|
162 | }
|
163 | else {
|
164 | writer.move(writer.createRangeOn(viewImgOrPicture), writer.createPositionAt(viewFigure, 0));
|
165 | writer.remove(linkInImage);
|
166 | }
|
167 | }
|
168 | else {
|
169 | // But if it does not exist. Let's wrap already converted image by newly created link element.
|
170 | // 1. Create an empty link element.
|
171 | const linkElement = writer.createContainerElement('a', { href: data.attributeNewValue });
|
172 | // 2. Insert link inside the associated image.
|
173 | writer.insert(writer.createPositionAt(viewFigure, 0), linkElement);
|
174 | // 3. Move the image to the link.
|
175 | writer.move(writer.createRangeOn(viewImgOrPicture), writer.createPositionAt(linkElement, 0));
|
176 | }
|
177 | }, { priority: 'high' });
|
178 | };
|
179 | }
|
180 | /**
|
181 | * Returns a converter that decorates the `<a>` element when the image is the link label.
|
182 | */
|
183 | function downcastImageLinkManualDecorator(decorator) {
|
184 | return dispatcher => {
|
185 | dispatcher.on(`attribute:${decorator.id}:imageBlock`, (evt, data, conversionApi) => {
|
186 | const viewFigure = conversionApi.mapper.toViewElement(data.item);
|
187 | const linkInImage = Array.from(viewFigure.getChildren())
|
188 | .find((child) => child.is('element', 'a'));
|
189 | // The <a> element was removed by the time this converter is executed.
|
190 | // It may happen when the base `linkHref` and decorator attributes are removed
|
191 | // at the same time (see #8401).
|
192 | if (!linkInImage) {
|
193 | return;
|
194 | }
|
195 | for (const [key, val] of toMap(decorator.attributes)) {
|
196 | conversionApi.writer.setAttribute(key, val, linkInImage);
|
197 | }
|
198 | if (decorator.classes) {
|
199 | conversionApi.writer.addClass(decorator.classes, linkInImage);
|
200 | }
|
201 | for (const key in decorator.styles) {
|
202 | conversionApi.writer.setStyle(key, decorator.styles[key], linkInImage);
|
203 | }
|
204 | });
|
205 | };
|
206 | }
|
207 | /**
|
208 | * Returns a converter that checks whether manual decorators should be applied to the link.
|
209 | */
|
210 | function upcastImageLinkManualDecorator(editor, decorator) {
|
211 | const isImageInlinePluginLoaded = editor.plugins.has('ImageInlineEditing');
|
212 | const imageUtils = editor.plugins.get('ImageUtils');
|
213 | return dispatcher => {
|
214 | dispatcher.on('element:a', (evt, data, conversionApi) => {
|
215 | const viewLink = data.viewItem;
|
216 | const imageInLink = imageUtils.findViewImgElement(viewLink);
|
217 | // We need to check whether an image is inside a link because the converter handles
|
218 | // only manual decorators for linked images. See #7975.
|
219 | if (!imageInLink) {
|
220 | return;
|
221 | }
|
222 | const blockImageView = imageInLink.findAncestor(element => imageUtils.isBlockImageView(element));
|
223 | if (isImageInlinePluginLoaded && !blockImageView) {
|
224 | return;
|
225 | }
|
226 | const matcher = new Matcher(decorator._createPattern());
|
227 | const result = matcher.match(viewLink);
|
228 | // The link element does not have required attributes or/and proper values.
|
229 | if (!result) {
|
230 | return;
|
231 | }
|
232 | // Check whether we can consume those attributes.
|
233 | if (!conversionApi.consumable.consume(viewLink, result.match)) {
|
234 | return;
|
235 | }
|
236 | // At this stage we can assume that we have the `<imageBlock>` element.
|
237 | // `nodeBefore` comes after conversion: `<a><img></a>`.
|
238 | // `parent` comes with full image definition: `<figure><a><img></a></figure>.
|
239 | // See the body of the `upcastLink()` function.
|
240 | const modelElement = data.modelCursor.nodeBefore || data.modelCursor.parent;
|
241 | conversionApi.writer.setAttribute(decorator.id, true, modelElement);
|
242 | }, { priority: 'high' });
|
243 | // Using the same priority that `upcastLink()` converter guarantees that the linked image was properly converted.
|
244 | };
|
245 | }
|