@ckeditor/ckeditor5-link
Version:
Link feature for CKEditor 5.
1,089 lines (1,082 loc) • 141 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
*/
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 { 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, 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 { IconPreviousArrow as IconPreviousArrow$1, IconNextArrow } from '@ckeditor/ckeditor5-icons/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();
/**
* Gives information about the number of decorators stored in the {@link module:link/utils/automaticdecorators~AutomaticLinkDecorators}
* instance.
*/ get length() {
return this._definitions.size;
}
/**
* 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)=>{
dispatcher.on('attribute:linkHref', (evt, data, conversionApi)=>{
// 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 (!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;
}
const viewWriter = conversionApi.writer;
const viewSelection = viewWriter.document.selection;
for (const item of this._definitions){
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);
if (item.callback(data.attributeNewValue)) {
if (data.item.is('selection')) {
viewWriter.wrap(viewSelection.getFirstRange(), viewElement);
} else {
viewWriter.wrap(conversionApi.mapper.toViewRange(data.range), viewElement);
}
} else {
viewWriter.unwrap(conversionApi.mapper.toViewRange(data.range), viewElement);
}
}
}, {
priority: 'high'
});
};
}
/**
* 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)=>{
dispatcher.on('attribute:linkHref:imageBlock', (evt, data, { writer, mapper })=>{
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 item of this._definitions){
const attributes = toMap(item.attributes);
if (item.callback(data.attributeNewValue)) {
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, linkInImage);
}
}
if (item.classes) {
writer.addClass(item.classes, linkInImage);
}
for(const key in item.styles){
writer.setStyle(key, item.styles[key], linkInImage);
}
} else {
for (const [key, val] of attributes){
if (key === 'class') {
writer.removeClass(val, linkInImage);
} else {
writer.removeAttribute(key, linkInImage);
}
}
if (item.classes) {
writer.removeClass(item.classes, linkInImage);
}
for(const key in item.styles){
writer.removeStyle(key, linkInImage);
}
}
}
});
};
}
}
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;
}
/**
* 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;
// 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);
}
} 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;
}
}
/**
* 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();
}
/**
* 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);
if (automaticDecorators.length) {
editor.conversion.for('downcast').add(automaticDecorators.getDispatcher());
}
}
/**
* Processes an array of configured {@link module:link/linkconfig~LinkDecoratorManualDefinition manual decorators},
* transforms them into {@link module:link/utils/manualdecorator~LinkManualDecorator} instances and stores them in the
* {@link module:link/linkcommand~LinkCommand#manualDecorators} collection (a model for manual decorators state).
*
* Also registers an {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement attribute-to-element}
* converter for each manual decorator and extends the {@link module:engine/model/schema~ModelSchema model's schema}
* with adequate model attributes.
*/ _enableManualDecorators(manualDecoratorDefinitions) {
if (!manualDecoratorDefinitions.length) {
return;
}
const editor = this.editor;
const command = editor.commands.get('link');
const manualDecorators = command.manualDecorators;
manualDecoratorDefinitions.forEach((decoratorDefinition)=>{
editor.model.schema.extend('$text', {
allowAttributes: decoratorDefinition.id
});
// Keeps reference to manual decorator to decode its name to attributes during downcast.
const decorator = new LinkManualDecorator(decoratorDefinition);
manualDecorators.add(decorator);
editor.conversion.for('downcast').attributeToElement({
model: decorator.id,
view: (manualDecoratorValue, { writer, schema }, { item })=>{
// Manual decorators for block links are handled e.g. in LinkImageEditing.
if (!(item.is('selection') || schema.isInline(item))) {
return;
}
if (manualDecoratorValue) {
const element = writer.createAttributeElement('a', decorator.attributes, {
priority: 5
});
if (decorator.classes) {
writer.addClass(decorator.classes, element);
}
for(const key in decorator.styles){
writer.setStyle(key, decorator.styles[key], element);
}
writer.setCustomProperty('link', true, element);
return element;
}
}
});
editor.conversion.for('upcast').elementToAttribute({
view: {
name: 'a',
...decorator._createPattern()
},
model: {
key: decorator.id
}
});
});
}
/**
* Attaches handlers for {@link module:engine/view/document~ViewDocument#event:enter} and
* {@link module:engine/view/document~ViewDocument#event:click} to enable link following.
*/ _enableLinkOpen() {
const editor = this.editor;
const view = editor.editing.view;
const viewDocument = view.document;
const handleLinkOpening = (url)=>{
if (!this._linkOpeners.some((opener)=>opener(url))) {
openLink(url);
}
};
this.listenTo(viewDocument, 'click', (evt, data)=>{
const shouldOpen = env.isMac ? data.domEvent.metaKey : data.domEvent.ctrlKey;
if (!shouldOpen) {
return;
}
let clickedElement = data.domTarget;
if (clickedElement.tagName.toLowerCase() != 'a') {
clickedElement = clickedElement.closest('a');
}
if (!clickedElement) {
return;
}
const url = clickedElement.getAttribute('href');
if (!url) {
return;
}
evt.stop();
data.preventDefault();
handleLinkOpening(url);
}, {
context: '$capture'
});
// Open link on Alt+Enter.
this.listenTo(viewDocument, 'keydown', (evt, data)=>{
const linkCommand = editor.commands.get('link');
const url = linkCommand.value;
const shouldOpen = !!url && data.keyCode === keyCodes.enter && data.altKey;
if (!shouldOpen) {
return;
}
evt.stop();
handleLinkOpening(url);
});
}
/**
* Watches the ModelDocumentSelection attribute changes and removes link decorator attributes when the linkHref attribute is removed.
*
* This is to ensure that there is no left-over link decorator attributes on the document selection that is no longer in a link.
*/ _enableSelectionAttributesFixer() {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
this.listenTo(selection, 'change:attribute', (evt, { attributeKeys })=>{
if (!attributeKeys.includes('linkHref') || selection.hasAttribute('linkHref')) {
return;
}
model.change((writer)=>{
removeLinkAttributesFromSelection(writer, getLinkAttributesAllowedOnText(model.schema));
});
});
}
/**
* Enables URL fixing on pasting.
*/ _enableClipboardIntegration() {
const editor = this.editor;
const model = editor.model;
const defaultProtocol = this.editor.config.get('link.defaultProtocol');
if (!defaultProtocol) {
return;
}
this.listenTo(editor.plugins.get('ClipboardPipeline'), 'contentInsertion', (evt, data)=>{
model.change((writer)=>{
const range = writer.createRangeIn(data.content);
for (const item of range.getItems()){
if (item.hasAttribute('linkHref')) {
const newLink = addLinkProtocolIfApplicable(item.getAttribute('linkHref'), defaultProtocol);
writer.setAttribute('linkHref', newLink, item);
}
}
});
});
}
}
/**
* Make the selection free of link-related model attributes.
* All link-related model attributes start with "link". That includes not only "linkHref"
* but also all decorator attributes (they have dynamic names), or even custom plugins.
*/ function removeLinkAttributesFromSelection(writer, linkAttributes) {
writer.removeSelectionAttribute('linkHref');
for (const attribute of linkAttributes){
writer.removeSelectionAttribute(attribute);
}
}
/**
* Returns an array containing names of the attributes allowed on `$text` that describes the link item.
*/ function getLinkAttributesAllowedOnText(schema) {
const textAttributes = schema.getDefinition('$text').allowAttributes;
return textAttributes.filter((attribute)=>attribute.startsWith('link'));
}
/**
* The link button class. Rendered as an `<a>` tag with link opening in a new tab.
*
* Provides a custom `navigate` cancelable event.
*/ class LinkPreviewButtonView extends ButtonView {
/**
* @inheritDoc
*/ constructor(locale){
super(locale);
const bind = this.bindTemplate;
this.set({