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 | */
|
8 | import { Command } from 'ckeditor5/src/core';
|
9 | import { findAttributeRange } from 'ckeditor5/src/typing';
|
10 | import { Collection, first, toMap } from 'ckeditor5/src/utils';
|
11 | import AutomaticDecorators from './utils/automaticdecorators';
|
12 | import { isLinkableElement } from './utils';
|
13 | /**
|
14 | * The link command. It is used by the {@link module:link/link~Link link feature}.
|
15 | */
|
16 | export 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.
|
269 | function 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 | }
|