@ckeditor/ckeditor5-link
Version:
Link feature for CKEditor 5.
416 lines (415 loc) • 20.3 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module link/linkcommand
*/
import { Command } from 'ckeditor5/src/core.js';
import { findAttributeRange } from 'ckeditor5/src/typing.js';
import { Collection, diff, first, toMap } from 'ckeditor5/src/utils.js';
import { ModelLivePosition } from 'ckeditor5/src/engine.js';
import { AutomaticLinkDecorators } from './utils/automaticdecorators.js';
import { extractTextFromLinkRange, isLinkableElement } from './utils.js';
/**
* The link command. It is used by the {@link module:link/link~Link link feature}.
*/
export class LinkCommand extends Command {
/**
* A collection of {@link module:link/utils/manualdecorator~LinkManualDecorator manual decorators}
* corresponding to the {@link module:link/linkconfig~LinkConfig#decorators decorator configuration}.
*
* You can consider it a model with states of manual decorators added to the currently selected link.
*/
manualDecorators = new Collection();
/**
* An instance of the helper that ties together all {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition}
* that are used by the {@glink features/link link} and the {@glink features/images/images-linking linking images} features.
*/
automaticDecorators = new AutomaticLinkDecorators();
/**
* Synchronizes the state of {@link #manualDecorators} with the currently present elements in the model.
*/
restoreManualDecoratorStates() {
for (const manualDecorator of this.manualDecorators) {
manualDecorator.value = this._getDecoratorStateFromModel(manualDecorator.id);
}
}
/**
* @inheritDoc
*/
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const selectedElement = selection.getSelectedElement() || first(selection.getSelectedBlocks());
// A check for any integration that allows linking elements (e.g. `LinkImage`).
// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
if (isLinkableElement(selectedElement, model.schema)) {
this.value = selectedElement.getAttribute('linkHref');
this.isEnabled = model.schema.checkAttribute(selectedElement, 'linkHref');
}
else {
this.value = selection.getAttribute('linkHref');
this.isEnabled = model.schema.checkAttributeInSelection(selection, 'linkHref');
}
for (const manualDecorator of this.manualDecorators) {
manualDecorator.value = this._getDecoratorStateFromModel(manualDecorator.id);
}
}
/**
* Executes the command.
*
* When the selection is non-collapsed, the `linkHref` attribute will be applied to nodes inside the selection, but only to
* those nodes where the `linkHref` attribute is allowed (disallowed nodes will be omitted).
*
* When the selection is collapsed and is not inside the text with the `linkHref` attribute, a
* new {@link module:engine/model/text~ModelText text node} with the `linkHref` attribute will be inserted in place of the caret, but
* only if such element is allowed in this place. The `_data` of the inserted text will equal the `href` parameter.
* The selection will be updated to wrap the just inserted text node.
*
* When the selection is collapsed and inside the text with the `linkHref` attribute, the attribute value will be updated.
*
* # Decorators and model attribute management
*
* There is an optional argument to this command that applies or removes model
* {@glink framework/architecture/editing-engine#text-attributes text attributes} brought by
* {@link module:link/utils/manualdecorator~LinkManualDecorator manual link decorators}.
*
* Text attribute names in the model correspond to the entries in the {@link module:link/linkconfig~LinkConfig#decorators
* configuration}.
* For every decorator configured, a model text attribute exists with the "link" prefix. For example, a `'linkMyDecorator'` attribute
* corresponds to `'myDecorator'` in the configuration.
*
* To learn more about link decorators, check out the {@link module:link/linkconfig~LinkConfig#decorators `config.link.decorators`}
* documentation.
*
* Here is how to manage decorator attributes with the link command:
*
* ```ts
* const linkCommand = editor.commands.get( 'link' );
*
* // Adding a new decorator attribute.
* linkCommand.execute( 'http://example.com', {
* linkIsExternal: true
* } );
*
* // Removing a decorator attribute from the selection.
* linkCommand.execute( 'http://example.com', {
* linkIsExternal: false
* } );
*
* // Adding multiple decorator attributes at the same time.
* linkCommand.execute( 'http://example.com', {
* linkIsExternal: true,
* linkIsDownloadable: true,
* } );
*
* // Removing and adding decorator attributes at the same time.
* linkCommand.execute( 'http://example.com', {
* linkIsExternal: false,
* linkFoo: true,
* linkIsDownloadable: false,
* } );
* ```
*
* **Note**: If the decorator attribute name is not specified, its state remains untouched.
*
* **Note**: {@link module:link/unlinkcommand~UnlinkCommand#execute `UnlinkCommand#execute()`} removes all
* decorator attributes.
*
* An optional parameter called `displayedText` is to add or update text of the link that represents the `href`. For example:
*
* ```ts
* const linkCommand = editor.commands.get( 'link' );
*
* // Adding a new link with `displayedText` attribute.
* linkCommand.execute( 'http://example.com', {}, 'Example' );
* ```
*
* The above code will create an anchor like this:
*
* ```html
* <a href="http://example.com">Example</a>
* ```
*
* @fires execute
* @param href Link destination.
* @param manualDecoratorIds The information about manual decorator attributes to be applied or removed upon execution.
* @param displayedText Text of the link.
*/
execute(href, manualDecoratorIds = {}, displayedText) {
const model = this.editor.model;
const selection = model.document.selection;
// Stores information about manual decorators to turn them on/off when command is applied.
const truthyManualDecorators = [];
const falsyManualDecorators = [];
for (const name in manualDecoratorIds) {
if (manualDecoratorIds[name]) {
truthyManualDecorators.push(name);
}
else {
falsyManualDecorators.push(name);
}
}
model.change(writer => {
const updateLinkAttributes = (itemOrRange) => {
writer.setAttribute('linkHref', href, itemOrRange);
truthyManualDecorators.forEach(item => writer.setAttribute(item, true, itemOrRange));
falsyManualDecorators.forEach(item => writer.removeAttribute(item, itemOrRange));
};
const updateLinkTextIfNeeded = (range, linkHref) => {
const linkText = extractTextFromLinkRange(range);
if (!linkText) {
return range;
}
// Make a copy not to override the command param value.
let newText = displayedText;
if (!newText) {
// Replace the link text with the new href if previously href was equal to text.
// For example: `<a href="http://ckeditor.com/">http://ckeditor.com/</a>`.
newText = linkHref && linkHref == linkText ? href : linkText;
}
// Only if needed.
if (newText != linkText) {
const changes = findChanges(linkText, newText);
let insertsLength = 0;
for (const { offset, actual, expected } of changes) {
const updatedOffset = offset + insertsLength;
const subRange = writer.createRange(range.start.getShiftedBy(updatedOffset), range.start.getShiftedBy(updatedOffset + actual.length));
// Collect formatting attributes from replaced text.
const textNode = getLinkPartTextNode(subRange, range);
const attributes = textNode.getAttributes();
const formattingAttributes = Array
.from(attributes)
.filter(([key]) => model.schema.getAttributeProperties(key).isFormatting);
// Create a new text node.
const newTextNode = writer.createText(expected, formattingAttributes);
// Set link attributes before inserting to document to avoid Differ attributes edge case.
updateLinkAttributes(newTextNode);
// Replace text with formatting.
model.insertContent(newTextNode, subRange);
// Sum of all previous inserts.
insertsLength += expected.length;
}
return writer.createRange(range.start, range.start.getShiftedBy(newText.length));
}
};
const collapseSelectionAtLinkEnd = (linkRange) => {
const { plugins } = this.editor;
writer.setSelection(linkRange.end);
if (plugins.has('TwoStepCaretMovement')) {
// After replacing the text of the link, we need to move the caret to the end of the link,
// override it's gravity to forward to prevent keeping e.g. bold attribute on the caret
// which was previously inside the link.
//
// If the plugin is not available, the caret will be placed at the end of the link and the
// bold attribute will be kept even if command moved caret outside the link.
plugins.get('TwoStepCaretMovement')._handleForwardMovement();
}
else {
// Remove the `linkHref` attribute and all link decorators from the selection.
// It stops adding a new content into the link element.
for (const key of ['linkHref', ...truthyManualDecorators, ...falsyManualDecorators]) {
writer.removeSelectionAttribute(key);
}
}
};
// If selection is collapsed then update selected link or insert new one at the place of caret.
if (selection.isCollapsed) {
const position = selection.getFirstPosition();
// When selection is inside text with `linkHref` attribute.
if (selection.hasAttribute('linkHref')) {
const linkHref = selection.getAttribute('linkHref');
const linkRange = findAttributeRange(position, 'linkHref', linkHref, model);
const newLinkRange = updateLinkTextIfNeeded(linkRange, linkHref);
updateLinkAttributes(newLinkRange || linkRange);
// Put the selection at the end of the updated link only when text was changed.
// When text was not altered we keep the original selection.
if (newLinkRange) {
collapseSelectionAtLinkEnd(newLinkRange);
}
}
// If not then insert text node with `linkHref` attribute in place of caret.
// However, since selection is collapsed, attribute value will be used as data for text node.
// So, if `href` is empty, do not create text node.
else if (href !== '') {
const attributes = toMap(selection.getAttributes());
attributes.set('linkHref', href);
truthyManualDecorators.forEach(item => {
attributes.set(item, true);
});
const newLinkRange = model.insertContent(writer.createText(displayedText || href, attributes), position);
// Put the selection at the end of the inserted link.
// Using end of range returned from insertContent in case nodes with the same attributes got merged.
collapseSelectionAtLinkEnd(newLinkRange);
}
}
else {
// Non-collapsed selection.
// If selection has non-collapsed ranges, we change attribute on nodes inside those ranges
// omitting nodes where the `linkHref` attribute is disallowed.
const selectionRanges = Array.from(selection.getRanges());
const ranges = model.schema.getValidRanges(selectionRanges, 'linkHref');
// But for the first, check whether the `linkHref` attribute is allowed on selected blocks (e.g. the "image" element).
const allowedRanges = [];
for (const element of selection.getSelectedBlocks()) {
if (model.schema.checkAttribute(element, 'linkHref')) {
allowedRanges.push(writer.createRangeOn(element));
}
}
// Ranges that accept the `linkHref` attribute. Since we will iterate over `allowedRanges`, let's clone it.
const rangesToUpdate = allowedRanges.slice();
// For all selection ranges we want to check whether given range is inside an element that accepts the `linkHref` attribute.
// If so, we don't want to propagate applying the attribute to its children.
for (const range of ranges) {
if (this._isRangeToUpdate(range, allowedRanges)) {
rangesToUpdate.push(range);
}
}
// Store the selection ranges in a pseudo live range array (stickiness to the outside of the range).
const stickyPseudoRanges = selectionRanges.map(range => ({
start: ModelLivePosition.fromPosition(range.start, 'toPrevious'),
end: ModelLivePosition.fromPosition(range.end, 'toNext')
}));
// Update or set links (including text update if needed).
for (let range of rangesToUpdate) {
const linkHref = (range.start.textNode || range.start.nodeAfter).getAttribute('linkHref');
range = updateLinkTextIfNeeded(range, linkHref) || range;
updateLinkAttributes(range);
}
// The original selection got trimmed by replacing content so we need to restore it.
writer.setSelection(stickyPseudoRanges.map(pseudoRange => {
const start = pseudoRange.start.toPosition();
const end = pseudoRange.end.toPosition();
pseudoRange.start.detach();
pseudoRange.end.detach();
return model.createRange(start, end);
}));
}
});
}
/**
* Provides information whether a decorator with a given name is present in the currently processed selection.
*
* @param decoratorName The name of the manual decorator used in the model
* @returns The information whether a given decorator is currently present in the selection.
*/
_getDecoratorStateFromModel(decoratorName) {
const model = this.editor.model;
const selection = model.document.selection;
const selectedElement = selection.getSelectedElement();
// A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
if (isLinkableElement(selectedElement, model.schema)) {
return selectedElement.getAttribute(decoratorName);
}
return selection.getAttribute(decoratorName);
}
/**
* Checks whether specified `range` is inside an element that accepts the `linkHref` attribute.
*
* @param range A range to check.
* @param allowedRanges An array of ranges created on elements where the attribute is accepted.
*/
_isRangeToUpdate(range, allowedRanges) {
for (const allowedRange of allowedRanges) {
// A range is inside an element that will have the `linkHref` attribute. Do not modify its nodes.
if (allowedRange.containsRange(range)) {
return false;
}
}
return true;
}
}
/**
* Compares two strings and returns an array of changes needed to transform one into another.
* Uses the diff utility to find the differences and groups them into chunks containing information
* about the offset and actual/expected content.
*
* @param oldText The original text to compare.
* @param newText The new text to compare against.
* @returns Array of change objects containing offset and actual/expected content.
*
* @example
* findChanges( 'hello world', 'hi there' );
*
* Returns:
* [
* {
* "offset": 1,
* "actual": "ello",
* "expected": "i"
* },
* {
* "offset": 2,
* "actual": "wo",
* "expected": "the"
* },
* {
* "offset": 3,
* "actual": "ld",
* "expected": "e"
* }
* ]
*/
function findChanges(oldText, newText) {
// Get array of operations (insert/delete/equal) needed to transform oldText into newText.
// Example: diff('abc', 'abxc') returns ['equal', 'equal', 'insert', 'equal']
const changes = diff(oldText, newText);
// Track position in both strings based on operation type.
const counter = { equal: 0, insert: 0, delete: 0 };
const result = [];
// Accumulate consecutive changes into slices before creating change objects.
let actualSlice = '';
let expectedSlice = '';
// Adding null as sentinel value to handle final accumulated changes.
for (const action of [...changes, null]) {
if (action == 'insert') {
// Example: for 'abc' -> 'abxc', at insert position, adds 'x' to expectedSlice.
expectedSlice += newText[counter.equal + counter.insert];
}
else if (action == 'delete') {
// Example: for 'abc' -> 'ac', at delete position, adds 'b' to actualSlice.
actualSlice += oldText[counter.equal + counter.delete];
}
else if (actualSlice.length || expectedSlice.length) {
// On 'equal' or end: bundle accumulated changes into a single change object.
// Example: { offset: 2, actual: "", expected: "x" }
result.push({
offset: counter.equal,
actual: actualSlice,
expected: expectedSlice
});
actualSlice = '';
expectedSlice = '';
}
// Increment appropriate counter for the current operation.
if (action) {
counter[action]++;
}
}
return result;
}
/**
* Returns text node withing the link range that should be updated.
*
* @param range Partial link range.
* @param linkRange Range of the entire link.
* @returns Text node.
*/
function getLinkPartTextNode(range, linkRange) {
if (!range.isCollapsed) {
return first(range.getItems());
}
const position = range.start;
if (position.textNode) {
return position.textNode;
}
// If the range is at the start of a link range then prefer node inside a link range.
if (!position.nodeBefore || position.isEqual(linkRange.start)) {
return position.nodeAfter;
}
else {
return position.nodeBefore;
}
}