UNPKG

14.2 kBJavaScriptView Raw
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/linkcommand
7 */
8import { Command } from 'ckeditor5/src/core';
9import { findAttributeRange } from 'ckeditor5/src/typing';
10import { Collection, first, toMap } from 'ckeditor5/src/utils';
11import AutomaticDecorators from './utils/automaticdecorators';
12import { isLinkableElement } from './utils';
13/**
14 * The link command. It is used by the {@link module:link/link~Link link feature}.
15 */
16export default class LinkCommand extends Command {
17 constructor() {
18 super(...arguments);
19 /**
20 * A collection of {@link module:link/utils/manualdecorator~ManualDecorator manual decorators}
21 * corresponding to the {@link module:link/linkconfig~LinkConfig#decorators decorator configuration}.
22 *
23 * You can consider it a model with states of manual decorators added to the currently selected link.
24 */
25 this.manualDecorators = new Collection();
26 /**
27 * An instance of the helper that ties together all {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition}
28 * that are used by the {@glink features/link link} and the {@glink features/images/images-linking linking images} features.
29 */
30 this.automaticDecorators = new AutomaticDecorators();
31 }
32 /**
33 * Synchronizes the state of {@link #manualDecorators} with the currently present elements in the model.
34 */
35 restoreManualDecoratorStates() {
36 for (const manualDecorator of this.manualDecorators) {
37 manualDecorator.value = this._getDecoratorStateFromModel(manualDecorator.id);
38 }
39 }
40 /**
41 * @inheritDoc
42 */
43 refresh() {
44 const model = this.editor.model;
45 const selection = model.document.selection;
46 const selectedElement = selection.getSelectedElement() || first(selection.getSelectedBlocks());
47 // A check for any integration that allows linking elements (e.g. `LinkImage`).
48 // Currently the selection reads attributes from text nodes only. See #7429 and #7465.
49 if (isLinkableElement(selectedElement, model.schema)) {
50 this.value = selectedElement.getAttribute('linkHref');
51 this.isEnabled = model.schema.checkAttribute(selectedElement, 'linkHref');
52 }
53 else {
54 this.value = selection.getAttribute('linkHref');
55 this.isEnabled = model.schema.checkAttributeInSelection(selection, 'linkHref');
56 }
57 for (const manualDecorator of this.manualDecorators) {
58 manualDecorator.value = this._getDecoratorStateFromModel(manualDecorator.id);
59 }
60 }
61 /**
62 * Executes the command.
63 *
64 * When the selection is non-collapsed, the `linkHref` attribute will be applied to nodes inside the selection, but only to
65 * those nodes where the `linkHref` attribute is allowed (disallowed nodes will be omitted).
66 *
67 * When the selection is collapsed and is not inside the text with the `linkHref` attribute, a
68 * new {@link module:engine/model/text~Text text node} with the `linkHref` attribute will be inserted in place of the caret, but
69 * only if such element is allowed in this place. The `_data` of the inserted text will equal the `href` parameter.
70 * The selection will be updated to wrap the just inserted text node.
71 *
72 * When the selection is collapsed and inside the text with the `linkHref` attribute, the attribute value will be updated.
73 *
74 * # Decorators and model attribute management
75 *
76 * There is an optional argument to this command that applies or removes model
77 * {@glink framework/architecture/editing-engine#text-attributes text attributes} brought by
78 * {@link module:link/utils/manualdecorator~ManualDecorator manual link decorators}.
79 *
80 * Text attribute names in the model correspond to the entries in the {@link module:link/linkconfig~LinkConfig#decorators
81 * configuration}.
82 * For every decorator configured, a model text attribute exists with the "link" prefix. For example, a `'linkMyDecorator'` attribute
83 * corresponds to `'myDecorator'` in the configuration.
84 *
85 * To learn more about link decorators, check out the {@link module:link/linkconfig~LinkConfig#decorators `config.link.decorators`}
86 * documentation.
87 *
88 * Here is how to manage decorator attributes with the link command:
89 *
90 * ```ts
91 * const linkCommand = editor.commands.get( 'link' );
92 *
93 * // Adding a new decorator attribute.
94 * linkCommand.execute( 'http://example.com', {
95 * linkIsExternal: true
96 * } );
97 *
98 * // Removing a decorator attribute from the selection.
99 * linkCommand.execute( 'http://example.com', {
100 * linkIsExternal: false
101 * } );
102 *
103 * // Adding multiple decorator attributes at the same time.
104 * linkCommand.execute( 'http://example.com', {
105 * linkIsExternal: true,
106 * linkIsDownloadable: true,
107 * } );
108 *
109 * // Removing and adding decorator attributes at the same time.
110 * linkCommand.execute( 'http://example.com', {
111 * linkIsExternal: false,
112 * linkFoo: true,
113 * linkIsDownloadable: false,
114 * } );
115 * ```
116 *
117 * **Note**: If the decorator attribute name is not specified, its state remains untouched.
118 *
119 * **Note**: {@link module:link/unlinkcommand~UnlinkCommand#execute `UnlinkCommand#execute()`} removes all
120 * decorator attributes.
121 *
122 * @fires execute
123 * @param href Link destination.
124 * @param manualDecoratorIds The information about manual decorator attributes to be applied or removed upon execution.
125 */
126 execute(href, manualDecoratorIds = {}) {
127 const model = this.editor.model;
128 const selection = model.document.selection;
129 // Stores information about manual decorators to turn them on/off when command is applied.
130 const truthyManualDecorators = [];
131 const falsyManualDecorators = [];
132 for (const name in manualDecoratorIds) {
133 if (manualDecoratorIds[name]) {
134 truthyManualDecorators.push(name);
135 }
136 else {
137 falsyManualDecorators.push(name);
138 }
139 }
140 model.change(writer => {
141 // If selection is collapsed then update selected link or insert new one at the place of caret.
142 if (selection.isCollapsed) {
143 const position = selection.getFirstPosition();
144 // When selection is inside text with `linkHref` attribute.
145 if (selection.hasAttribute('linkHref')) {
146 const linkText = extractTextFromSelection(selection);
147 // Then update `linkHref` value.
148 let linkRange = findAttributeRange(position, 'linkHref', selection.getAttribute('linkHref'), model);
149 if (selection.getAttribute('linkHref') === linkText) {
150 linkRange = this._updateLinkContent(model, writer, linkRange, href);
151 }
152 writer.setAttribute('linkHref', href, linkRange);
153 truthyManualDecorators.forEach(item => {
154 writer.setAttribute(item, true, linkRange);
155 });
156 falsyManualDecorators.forEach(item => {
157 writer.removeAttribute(item, linkRange);
158 });
159 // Put the selection at the end of the updated link.
160 writer.setSelection(writer.createPositionAfter(linkRange.end.nodeBefore));
161 }
162 // If not then insert text node with `linkHref` attribute in place of caret.
163 // However, since selection is collapsed, attribute value will be used as data for text node.
164 // So, if `href` is empty, do not create text node.
165 else if (href !== '') {
166 const attributes = toMap(selection.getAttributes());
167 attributes.set('linkHref', href);
168 truthyManualDecorators.forEach(item => {
169 attributes.set(item, true);
170 });
171 const { end: positionAfter } = model.insertContent(writer.createText(href, attributes), position);
172 // Put the selection at the end of the inserted link.
173 // Using end of range returned from insertContent in case nodes with the same attributes got merged.
174 writer.setSelection(positionAfter);
175 }
176 // Remove the `linkHref` attribute and all link decorators from the selection.
177 // It stops adding a new content into the link element.
178 ['linkHref', ...truthyManualDecorators, ...falsyManualDecorators].forEach(item => {
179 writer.removeSelectionAttribute(item);
180 });
181 }
182 else {
183 // If selection has non-collapsed ranges, we change attribute on nodes inside those ranges
184 // omitting nodes where the `linkHref` attribute is disallowed.
185 const ranges = model.schema.getValidRanges(selection.getRanges(), 'linkHref');
186 // But for the first, check whether the `linkHref` attribute is allowed on selected blocks (e.g. the "image" element).
187 const allowedRanges = [];
188 for (const element of selection.getSelectedBlocks()) {
189 if (model.schema.checkAttribute(element, 'linkHref')) {
190 allowedRanges.push(writer.createRangeOn(element));
191 }
192 }
193 // Ranges that accept the `linkHref` attribute. Since we will iterate over `allowedRanges`, let's clone it.
194 const rangesToUpdate = allowedRanges.slice();
195 // For all selection ranges we want to check whether given range is inside an element that accepts the `linkHref` attribute.
196 // If so, we don't want to propagate applying the attribute to its children.
197 for (const range of ranges) {
198 if (this._isRangeToUpdate(range, allowedRanges)) {
199 rangesToUpdate.push(range);
200 }
201 }
202 for (const range of rangesToUpdate) {
203 let linkRange = range;
204 if (rangesToUpdate.length === 1) {
205 // Current text of the link in the document.
206 const linkText = extractTextFromSelection(selection);
207 if (selection.getAttribute('linkHref') === linkText) {
208 linkRange = this._updateLinkContent(model, writer, range, href);
209 writer.setSelection(writer.createSelection(linkRange));
210 }
211 }
212 writer.setAttribute('linkHref', href, linkRange);
213 truthyManualDecorators.forEach(item => {
214 writer.setAttribute(item, true, linkRange);
215 });
216 falsyManualDecorators.forEach(item => {
217 writer.removeAttribute(item, linkRange);
218 });
219 }
220 }
221 });
222 }
223 /**
224 * Provides information whether a decorator with a given name is present in the currently processed selection.
225 *
226 * @param decoratorName The name of the manual decorator used in the model
227 * @returns The information whether a given decorator is currently present in the selection.
228 */
229 _getDecoratorStateFromModel(decoratorName) {
230 const model = this.editor.model;
231 const selection = model.document.selection;
232 const selectedElement = selection.getSelectedElement();
233 // A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
234 // Currently the selection reads attributes from text nodes only. See #7429 and #7465.
235 if (isLinkableElement(selectedElement, model.schema)) {
236 return selectedElement.getAttribute(decoratorName);
237 }
238 return selection.getAttribute(decoratorName);
239 }
240 /**
241 * Checks whether specified `range` is inside an element that accepts the `linkHref` attribute.
242 *
243 * @param range A range to check.
244 * @param allowedRanges An array of ranges created on elements where the attribute is accepted.
245 */
246 _isRangeToUpdate(range, allowedRanges) {
247 for (const allowedRange of allowedRanges) {
248 // A range is inside an element that will have the `linkHref` attribute. Do not modify its nodes.
249 if (allowedRange.containsRange(range)) {
250 return false;
251 }
252 }
253 return true;
254 }
255 /**
256 * Updates selected link with a new value as its content and as its href attribute.
257 *
258 * @param model Model is need to insert content.
259 * @param writer Writer is need to create text element in model.
260 * @param range A range where should be inserted content.
261 * @param href A link value which should be in the href attribute and in the content.
262 */
263 _updateLinkContent(model, writer, range, href) {
264 const text = writer.createText(href, { linkHref: href });
265 return model.insertContent(text, range);
266 }
267}
268// Returns a text of a link under the collapsed selection or a selection that contains the entire link.
269function extractTextFromSelection(selection) {
270 if (selection.isCollapsed) {
271 const firstPosition = selection.getFirstPosition();
272 return firstPosition.textNode && firstPosition.textNode.data;
273 }
274 else {
275 const rangeItems = Array.from(selection.getFirstRange().getItems());
276 if (rangeItems.length > 1) {
277 return null;
278 }
279 const firstNode = rangeItems[0];
280 if (firstNode.is('$text') || firstNode.is('$textProxy')) {
281 return firstNode.data;
282 }
283 return null;
284 }
285}