UNPKG

@ckeditor/ckeditor5-link

Version:

Link feature for CKEditor 5.

1,089 lines (1,082 loc) • 141 kB
/** * @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({