@ckeditor/ckeditor5-link
Version:
Link feature for CKEditor 5.
1,065 lines (1,058 loc) • 152 kB
JavaScript
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Command, Plugin } from '@ckeditor/ckeditor5-core/dist/index.js';
import { findAttributeRange, TwoStepCaretMovement, Input, inlineHighlight, Delete, TextWatcher, getLastTextLine } from '@ckeditor/ckeditor5-typing/dist/index.js';
import { ClipboardPipeline } from '@ckeditor/ckeditor5-clipboard/dist/index.js';
import { priorities, toMap, Collection, first, diff, ObservableMixin, env, keyCodes, FocusTracker, KeystrokeHandler } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { ModelLivePosition, ClickObserver, Matcher } from '@ckeditor/ckeditor5-engine/dist/index.js';
import { upperFirst } from 'es-toolkit/compat';
import { IconPreviousArrow, IconNextArrow, IconUnlink, IconPencil, IconSettings, IconLink } from '@ckeditor/ckeditor5-icons/dist/index.js';
import { ButtonView, View, ViewCollection, FocusCycler, submitHandler, FormHeaderView, ListView, ListItemView, LabeledFieldView, createLabeledInputText, FormRowView, IconView, ContextualBalloon, ToolbarView, CssTransitionDisablerMixin, SwitchButtonView, MenuBarMenuListItemButtonView, clickOutsideHandler } from '@ckeditor/ckeditor5-ui/dist/index.js';
import { isWidget } from '@ckeditor/ckeditor5-widget/dist/index.js';
import { ImageEditing, ImageUtils, ImageBlockEditing } from '@ckeditor/ckeditor5-image/dist/index.js';
/**
* Helper class that ties together all {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition} and provides
* the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement downcast dispatchers} for them.
*/ class AutomaticLinkDecorators {
/**
* Stores the definition of {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition automatic decorators}.
* This data is used as a source for a downcast dispatcher to create a proper conversion to output data.
*/ _definitions = new Set();
/**
* A callback that checks if a decorator can be applied to a given element.
* Returns `true` if there is a conflict preventing the decorator from being applied.
*/ _conflictChecker;
/**
* Gives information about the number of decorators stored in the {@link module:link/utils/automaticdecorators~AutomaticLinkDecorators}
* instance.
*/ get length() {
return this._definitions.size;
}
/**
* Sets a callback that checks if a decorator can be applied to a given element.
*
* @param checker A function that returns `true` if there is a conflict preventing the decorator from being applied.
*/ setConflictChecker(checker) {
this._conflictChecker = checker;
}
/**
* Adds automatic decorator objects or an array with them to be used during downcasting.
*
* @param item A configuration object of automatic rules for decorating links. It might also be an array of such objects.
*/ add(item) {
if (Array.isArray(item)) {
item.forEach((item)=>this._definitions.add(item));
} else {
this._definitions.add(item);
}
}
/**
* Provides the conversion helper used in the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add} method.
*
* @returns A dispatcher function used as conversion helper in {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add}.
*/ getDispatcher() {
return (dispatcher)=>{
const elementCreator = (item, viewWriter)=>{
const viewElement = viewWriter.createAttributeElement('a', item.attributes, {
priority: 5
});
if (item.classes) {
viewWriter.addClass(item.classes, viewElement);
}
for(const key in item.styles){
viewWriter.setStyle(key, item.styles[key], viewElement);
}
viewWriter.setCustomProperty('link', true, viewElement);
return viewElement;
};
const createConverter = (isApplyingConverter)=>{
return (evt, data, conversionApi)=>{
if (!data.attributeKey.startsWith('link')) {
return;
}
// There is only test as this behavior decorates links and
// it is run before dispatcher which actually consumes this node.
// This allows on writing own dispatcher with highest priority,
// which blocks both native converter and this additional decoration.
if (data.attributeKey == 'linkHref' && !conversionApi.consumable.test(data.item, 'attribute:linkHref')) {
return;
}
// Automatic decorators for block links are handled e.g. in LinkImageEditing.
if (!data.item.is('selection') && !conversionApi.schema.isInline(data.item)) {
return;
}
for (const decorator of this._definitions){
// Check if automatic decorator is matched and does not conflict with any other active manual decorator.
if (decorator.callback(data.item.getAttribute('linkHref')) && !this._conflictChecker?.(decorator, data.item) && isApplyingConverter) {
if (data.item.is('selection')) {
conversionApi.writer.wrap(conversionApi.writer.document.selection.getFirstRange(), elementCreator(decorator, conversionApi.writer));
} else {
conversionApi.writer.wrap(conversionApi.mapper.toViewRange(data.range), elementCreator(decorator, conversionApi.writer));
}
} else {
conversionApi.writer.unwrap(conversionApi.mapper.toViewRange(data.range), elementCreator(decorator, conversionApi.writer));
}
}
};
};
dispatcher.on('attribute', createConverter(false), {
priority: priorities.high - 1
});
// Apply decorators after all automatic and manual decorators are removed so removing one decorator
// won't strip part of the other decorator's attributes, classes or styles.
dispatcher.on('attribute', createConverter(true), {
priority: priorities.high - 2
});
};
}
/**
* Provides the conversion helper used in the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add} method
* when linking images.
*
* @returns A dispatcher function used as conversion helper in {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add}.
*/ getDispatcherForLinkedImage() {
return (dispatcher)=>{
const createConverter = (isApplyingConverter)=>{
return (evt, data, { writer, mapper })=>{
if (!data.item.is('element', 'imageBlock') || !data.attributeKey.startsWith('link')) {
return;
}
const viewFigure = mapper.toViewElement(data.item);
const linkInImage = Array.from(viewFigure.getChildren()).find((child)=>child.is('element', 'a'));
// It's not guaranteed that the anchor is present in the image block during execution of this dispatcher.
// It might have been removed during the execution of unlink command that runs the image link downcast dispatcher
// that is executed before this one and removes the anchor from the image block.
if (!linkInImage) {
return;
}
for (const decorator of this._definitions){
const attributes = toMap(decorator.attributes);
if (decorator.callback(data.item.getAttribute('linkHref')) && !this._conflictChecker?.(decorator, data.item) && isApplyingConverter) {
for (const [key, val] of attributes){
// Left for backward compatibility. Since v30 decorator should
// accept `classes` and `styles` separately from `attributes`.
if (key === 'class') {
writer.addClass(val, linkInImage);
} else {
writer.setAttribute(key, val, false, linkInImage);
}
}
if (decorator.classes) {
writer.addClass(decorator.classes, linkInImage);
}
for(const key in decorator.styles){
writer.setStyle(key, decorator.styles[key], linkInImage);
}
} else {
for (const [key, val] of attributes){
if (key === 'class') {
writer.removeClass(val, linkInImage);
} else {
writer.removeAttribute(key, val, linkInImage);
}
}
if (decorator.classes) {
writer.removeClass(decorator.classes, linkInImage);
}
for(const key in decorator.styles){
writer.removeStyle(key, linkInImage);
}
}
}
};
};
dispatcher.on('attribute', createConverter(false), {
priority: priorities.high - 1
});
// Apply decorators after all automatic and manual decorators are removed so removing one decorator
// won't strip part of the other decorator's attributes, classes or styles.
dispatcher.on('attribute', createConverter(true), {
priority: priorities.high - 2
});
};
}
}
const ATTRIBUTE_WHITESPACES = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g; // eslint-disable-line no-control-regex
const SAFE_URL_TEMPLATE = '^(?:(?:<protocols>):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))';
// Simplified email test - should be run over previously found URL.
const EMAIL_REG_EXP = /^[\S]+@((?![-_])(?:[-\w\u00a1-\uffff]{0,63}[^-_]\.))+(?:[a-z\u00a1-\uffff]{2,})$/i;
// The regex checks for the protocol syntax ('xxxx://' or 'xxxx:')
// or non-word characters at the beginning of the link ('/', '#' etc.).
const PROTOCOL_REG_EXP = /^((\w+:(\/{2,})?)|(\W))/i;
const DEFAULT_LINK_PROTOCOLS = [
'https?',
'ftps?',
'mailto'
];
/**
* A keystroke used by the {@link module:link/linkui~LinkUI link UI feature}.
*/ const LINK_KEYSTROKE = 'Ctrl+K';
/**
* Returns `true` if a given view node is the link element.
*/ function isLinkElement(node) {
return node.is('attributeElement') && !!node.getCustomProperty('link');
}
/**
* Creates a link {@link module:engine/view/attributeelement~ViewAttributeElement} with the provided `href` attribute.
*/ function createLinkElement(href, { writer }) {
// Priority 5 - https://github.com/ckeditor/ckeditor5-link/issues/121.
const linkElement = writer.createAttributeElement('a', {
href
}, {
priority: 5
});
writer.setCustomProperty('link', true, linkElement);
return linkElement;
}
/**
* Returns a safe URL based on a given value.
*
* A URL is considered safe if it is safe for the user (does not contain any malicious code).
*
* If a URL is considered unsafe, a simple `"#"` is returned.
*
* @internal
*/ function ensureSafeUrl(url, allowedProtocols = DEFAULT_LINK_PROTOCOLS) {
const urlString = String(url);
const protocolsList = allowedProtocols.join('|');
const customSafeRegex = new RegExp(`${SAFE_URL_TEMPLATE.replace('<protocols>', protocolsList)}`, 'i');
return isSafeUrl(urlString, customSafeRegex) ? urlString : '#';
}
/**
* Checks whether the given URL is safe for the user (does not contain any malicious code).
*/ function isSafeUrl(url, customRegexp) {
const normalizedUrl = url.replace(ATTRIBUTE_WHITESPACES, '');
return !!normalizedUrl.match(customRegexp);
}
/**
* Returns the {@link module:link/linkconfig~LinkConfig#decorators `config.link.decorators`} configuration processed
* to respect the locale of the editor, i.e. to display the {@link module:link/linkconfig~LinkDecoratorManualDefinition label}
* in the correct language.
*
* **Note**: Only the few most commonly used labels are translated automatically. Other labels should be manually
* translated in the {@link module:link/linkconfig~LinkConfig#decorators `config.link.decorators`} configuration.
*
* @param t Shorthand for {@link module:utils/locale~Locale#t Locale#t}.
* @param decorators The decorator reference where the label values should be localized.
* @internal
*/ function getLocalizedDecorators(t, decorators) {
const localizedDecoratorsLabels = {
'Open in a new tab': t('Open in a new tab'),
'Downloadable': t('Downloadable')
};
decorators.forEach((decorator)=>{
if ('label' in decorator && localizedDecoratorsLabels[decorator.label]) {
decorator.label = localizedDecoratorsLabels[decorator.label];
}
return decorator;
});
return decorators;
}
/**
* Converts an object with defined decorators to a normalized array of decorators. The `id` key is added for each decorator and
* is used as the attribute's name in the model.
*
* @internal
*/ function normalizeDecorators(decorators) {
const retArray = [];
if (decorators) {
for (const [key, value] of Object.entries(decorators)){
const decorator = Object.assign({}, value, {
id: `link${upperFirst(key)}`
});
retArray.push(decorator);
}
}
return retArray;
}
/**
* Returns `true` if the specified `element` can be linked (the element allows the `linkHref` attribute).
*/ function isLinkableElement(element, schema) {
if (!element) {
return false;
}
return schema.checkAttribute(element.name, 'linkHref');
}
/**
* Returns `true` if the specified `value` is an email.
*
* @internal
*/ function isEmail(value) {
return EMAIL_REG_EXP.test(value);
}
/**
* Adds the protocol prefix to the specified `link` when:
*
* * it does not contain it already, and there is a {@link module:link/linkconfig~LinkConfig#defaultProtocol `defaultProtocol` }
* configuration value provided,
* * or the link is an email address.
*/ function addLinkProtocolIfApplicable(link, defaultProtocol) {
const protocol = isEmail(link) ? 'mailto:' : defaultProtocol;
const isProtocolNeeded = !!protocol && !linkHasProtocol(link);
return link && isProtocolNeeded ? protocol + link : link;
}
/**
* Checks if protocol is already included in the link.
*
* @internal
*/ function linkHasProtocol(link) {
return PROTOCOL_REG_EXP.test(link);
}
/**
* Opens the link in a new browser tab.
*/ function openLink(link) {
window.open(link, '_blank', 'noopener');
}
/**
* Returns a text of a link range.
*
* If the returned value is `undefined`, the range contains elements other than text nodes.
*/ function extractTextFromLinkRange(range) {
let text = '';
for (const item of range.getItems()){
if (!item.is('$text') && !item.is('$textProxy')) {
return;
}
text += item.data;
}
return text;
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @module link/utils/conflictingdecorators
*/ /**
* Checks if two decorators conflict with each other.
*
* Decorators conflict when they share the same HTML attribute names (excluding mergeable attributes)
* or style properties.
*
* @internal
* @param a The first decorator.
* @param b The second decorator.
*/ function areDecoratorsConflicting(a, b) {
if (a.attributes && b.attributes) {
const hasConflict = Object.keys(a.attributes).some((key)=>!isMergeableAttribute(key) && key in b.attributes);
if (hasConflict) {
return true;
}
}
// Check for conflicting style properties (same CSS property names).
if (a.styles && b.styles) {
const hasConflict = Object.keys(a.styles).some((key)=>key in b.styles);
if (hasConflict) {
return true;
}
}
// Classes don't conflict with each other - they can be merged.
return false;
function isMergeableAttribute(key) {
return key === 'class' || key === 'style' || key === 'rel';
}
}
/**
* Resolves conflicting manual decorators by automatically disabling decorators that share
* the same HTML attributes with newly enabled decorators.
*
* @internal
* @param options Configuration object.
* @param options.decoratorStates Initial decorator states.
* @param options.allDecorators Collection of all manual decorators.
* @returns Updated decorator states with conflicts resolved.
*/ function resolveConflictingDecorators({ decoratorStates, allDecorators }) {
const resolved = {
...decoratorStates
};
for(const name in decoratorStates){
if (decoratorStates[name] && isNewlyAddedDecorator(name)) {
const conflicts = getConflictingManualDecorators(name, allDecorators);
for (const conflict of conflicts){
resolved[conflict] = false;
}
}
}
function isNewlyAddedDecorator(name) {
return allDecorators.some((item)=>item.id === name && !item.value);
}
return resolved;
}
/**
* Returns array of decorator names that conflict with the given decorator.
* Decorators conflict when they share the same HTML attribute names or style properties.
*
* @param decoratorId The id/name of the manual decorator to check for conflicts.
* @param manualDecorators Collection of all manual decorators.
* @returns Array of conflicting decorator names.
*/ function getConflictingManualDecorators(decoratorId, manualDecorators) {
const decorator = manualDecorators.find((item)=>item.id === decoratorId);
/* istanbul ignore next -- @preserve */ if (!decorator) {
return [];
}
return manualDecorators.filter((otherDecorator)=>otherDecorator.id !== decoratorId && areDecoratorsConflicting(decorator, otherDecorator)).map((item)=>item.id);
}
/**
* The link command. It is used by the {@link module:link/link~Link link feature}.
*/ 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;
// Resolve conflicting decorators and get the final decorator states.
const resolvedDecoratorsIds = resolveConflictingDecorators({
allDecorators: Array.from(this.manualDecorators),
decoratorStates: manualDecoratorIds
});
// Stores information about manual decorators to turn them on/off when command is applied.
const truthyManualDecorators = [];
const falsyManualDecorators = [];
for(const name in resolvedDecoratorsIds){
if (resolvedDecoratorsIds[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 fragment = writer.createDocumentFragment();
for (const item of range.getItems()){
// `extractTextFromLinkRange()` called above guarantees that we operate only on text proxies here.
const text = item;
writer.append(writer.createText(text.data, text.getAttributes()), fragment);
}
const fragRange = writer.createRangeIn(fragment);
const changes = findChanges(linkText, newText);
let insertsLength = 0;
for (const { offset, actual, expected } of changes){
const updatedOffset = offset + insertsLength;
const subRange = writer.createRange(fragRange.start.getShiftedBy(updatedOffset), fragRange.start.getShiftedBy(updatedOffset + actual.length));
// Collect formatting attributes from replaced text.
const textNode = getLinkPartTextNode(subRange, fragRange);
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.
writer.remove(subRange);
writer.insert(newTextNode, subRange.start);
// Sum of all previous inserts.
insertsLength += expected.length;
}
model.insertContent(fragment, range);
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);
}
} 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);
}));
}
});
this.restoreManualDecoratorStates();
}
/**
* 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;
}
}
/**
* The unlink command. It is used by the {@link module:link/link~Link link plugin}.
*/ class UnlinkCommand extends Command {
/**
* @inheritDoc
*/ refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const selectedElement = selection.getSelectedElement();
// 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.isEnabled = model.schema.checkAttribute(selectedElement, 'linkHref');
} else {
this.isEnabled = model.schema.checkAttributeInSelection(selection, 'linkHref');
}
}
/**
* Executes the command.
*
* When the selection is collapsed, it removes the `linkHref` attribute from each node with the same `linkHref` attribute value.
* When the selection is non-collapsed, it removes the `linkHref` attribute from each node in selected ranges.
*
* # Decorators
*
* If {@link module:link/linkconfig~LinkConfig#decorators `config.link.decorators`} is specified,
* all configured decorators are removed together with the `linkHref` attribute.
*
* @fires execute
*/ execute() {
const editor = this.editor;
const model = this.editor.model;
const selection = model.document.selection;
const linkCommand = editor.commands.get('link');
model.change((writer)=>{
// Get ranges to unlink.
const rangesToUnlink = selection.isCollapsed ? [
findAttributeRange(selection.getFirstPosition(), 'linkHref', selection.getAttribute('linkHref'), model)
] : model.schema.getValidRanges(selection.getRanges(), 'linkHref');
// Remove `linkHref` attribute from specified ranges.
for (const range of rangesToUnlink){
writer.removeAttribute('linkHref', range);
// If there are registered custom attributes, then remove them during unlink.
if (linkCommand) {
for (const manualDecorator of linkCommand.manualDecorators){
writer.removeAttribute(manualDecorator.id, range);
}
}
}
});
}
}
/**
* Helper class that stores manual decorators with observable {@link module:link/utils/manualdecorator~LinkManualDecorator#value}
* to support integration with the UI state. An instance of this class is a model with the state of individual manual decorators.
* These decorators are kept as collections in {@link module:link/linkcommand~LinkCommand#manualDecorators}.
*/ class LinkManualDecorator extends /* #__PURE__ */ ObservableMixin() {
/**
* An ID of a manual decorator which is the name of the attribute in the model, for example: 'linkManualDecorator0'.
*/ id;
/**
* The default value of manual decorator.
*/ defaultValue;
/**
* The label used in the user interface to toggle the manual decorator.
*/ label;
/**
* A set of attributes added to downcasted data when the decorator is activated for a specific link.
* Attributes should be added in a form of attributes defined in {@link module:engine/view/elementdefinition~ViewElementDefinition}.
*/ attributes;
/**
* A set of classes added to downcasted data when the decorator is activated for a specific link.
* Classes should be added in a form of classes defined in {@link module:engine/view/elementdefinition~ViewElementDefinition}.
*/ classes;
/**
* A set of styles added to downcasted data when the decorator is activated for a specific link.
* Styles should be added in a form of styles defined in {@link module:engine/view/elementdefinition~ViewElementDefinition}.
*/ styles;
/**
* Creates a new instance of {@link module:link/utils/manualdecorator~LinkManualDecorator}.
*
* @param options The configuration object.
*/ constructor({ id, label, attributes, classes, styles, defaultValue }){
super();
this.id = id;
this.set('value', undefined);
this.defaultValue = defaultValue;
this.label = label;
this.attributes = attributes;
this.classes = classes;
this.styles = styles;
}
/**
* Returns {@link module:engine/view/matcher~MatcherPattern} with decorator attributes.
*
* @internal
*/ _createPattern() {
return {
attributes: this.attributes,
classes: this.classes,
styles: this.styles
};
}
}
const HIGHLIGHT_CLASS = 'ck-link_selected';
const DECORATOR_AUTOMATIC = 'automatic';
const DECORATOR_MANUAL = 'manual';
const EXTERNAL_LINKS_REGEXP = /^(https?:)?\/\//;
/**
* The link engine feature.
*
* It introduces the `linkHref="url"` attribute in the model which renders to the view as a `<a href="url">` element
* as well as `'link'` and `'unlink'` commands.
*/ class LinkEditing extends Plugin {
/**
* A list of functions that handles opening links. If any of them returns `true`, the link is considered to be opened.
*/ _linkOpeners = [];
/**
* @inheritDoc
*/ static get pluginName() {
return 'LinkEditing';
}
/**
* @inheritDoc
*/ static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/ static get requires() {
// Clipboard is required for handling cut and paste events while typing over the link.
return [
TwoStepCaretMovement,
Input,
ClipboardPipeline
];
}
/**
* @inheritDoc
*/ constructor(editor){
super(editor);
editor.config.define('link', {
allowCreatingEmptyLinks: false,
addTargetToExternalLinks: false,
toolbar: [
'linkPreview',
'|',
'editLink',
'linkProperties',
'unlink'
]
});
}
/**
* @inheritDoc
*/ init() {
const editor = this.editor;
const allowedProtocols = this.editor.config.get('link.allowedProtocols');
// Allow link attribute on all inline nodes.
editor.model.schema.extend('$text', {
allowAttributes: 'linkHref'
});
editor.conversion.for('dataDowncast').attributeToElement({
model: 'linkHref',
view: createLinkElement
});
editor.conversion.for('editingDowncast').attributeToElement({
model: 'linkHref',
view: (href, conversionApi)=>{
return createLinkElement(ensureSafeUrl(href, allowedProtocols), conversionApi);
}
});
editor.conversion.for('upcast').elementToAttribute({
view: {
name: 'a',
attributes: {
href: true
}
},
model: {
key: 'linkHref',
value: (viewElement)=>viewElement.getAttribute('href')
}
});
// Create linking commands.
editor.commands.add('link', new LinkCommand(editor));
editor.commands.add('unlink', new UnlinkCommand(editor));
const linkDecorators = getLocalizedDecorators(editor.t, normalizeDecorators(editor.config.get('link.decorators')));
this._enableAutomaticDecorators(linkDecorators.filter((item)=>item.mode === DECORATOR_AUTOMATIC));
this._enableManualDecorators(linkDecorators.filter((item)=>item.mode === DECORATOR_MANUAL));
// Enable two-step caret movement for `linkHref` attribute.
const twoStepCaretMovementPlugin = editor.plugins.get(TwoStepCaretMovement);
twoStepCaretMovementPlugin.registerAttribute('linkHref');
// Setup highlight over selected link.
inlineHighlight(editor, 'linkHref', 'a', HIGHLIGHT_CLASS);
// Handle link following by CTRL+click or ALT+ENTER
this._enableLinkOpen();
// Clears the ModelDocumentSelection decorator attributes if the selection is no longer in a link (for example while using 2-SCM).
this._enableSelectionAttributesFixer();
// Handle adding default protocol to pasted links.
this._enableClipboardIntegration();
// Register postfixer that resolves conflicting decorator attributes.
this._enableDecoratorConflictPostfixer();
}
/**
* Registers a function that opens links in a new browser tab.
*
* @param linkOpener The function that opens a link in a new browser tab.
* @internal
*/ _registerLinkOpener(linkOpener) {
this._linkOpeners.push(linkOpener);
}
/**
* Processes an array of configured {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition automatic decorators}
* and registers a {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast dispatcher}
* for each one of them. Downcast dispatchers are obtained using the
* {@link module:link/utils/automaticdecorators~AutomaticLinkDecorators#getDispatcher} method.
*
* **Note**: This method also activates the automatic external link decorator if enabled with
* {@link module:link/linkconfig~LinkConfig#addTargetToExternalLinks `config.link.addTargetToExternalLinks`}.
*/ _enableAutomaticDecorators(automaticDecoratorDefinitions) {
const editor = this.editor;
// Store automatic decorators in the command instance as we do the same with manual decorators.
// Thanks to that, `LinkImageEditing` plugin can re-use the same definitions.
const command = editor.commands.get('link');
const automaticDecorators = command.automaticDecorators;
// Adds a default decorator for external links.
if (editor.config.get('link.addTargetToExternalLinks')) {
automaticDecorators.add({
id: 'linkIsExternal',
mode: DECORATOR_AUTOMATIC,
callback: (url)=>!!url && EXTERNAL_LINKS_REGEXP.test(url),
attributes: {
target: '_blank',
rel: 'noopener noreferrer'
}
});
}
automaticDecorators.add(automaticDecoratorDefinitions);
automaticDecorators.setConflictChecker((automaticDecorator, modelItem)=>{
for (const manualDecorator of command.manualDecorators){
// If manual decorator is not applied, skip it.
if (!modelItem.hasAttribute(manualDecorator.id)) {
continue;
}
// If it conflicts with manual decorator that was applied, return true
// to prevent the automatic decorator from being applied.
if (areDecoratorsConflicting(automaticDecorator, manualDecorator)) {
return true;
}
}
});
if (automaticDecorators.length) {
editor.conversion.for('downcast').add(automaticDecorators.getDispatcher());
}
}
/**
* Processes an array of con