@ckeditor/ckeditor5-link
Version:
Link feature for CKEditor 5.
3,188 lines • 111 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";
import { Delete, Input, TextWatcher, TwoStepCaretMovement, findAttributeRange, getLastTextLine, inlineHighlight } from "@ckeditor/ckeditor5-typing";
import { ClipboardPipeline } from "@ckeditor/ckeditor5-clipboard";
import { Collection, FocusTracker, KeystrokeHandler, ObservableMixin, diff, env, first, keyCodes, priorities, toMap } from "@ckeditor/ckeditor5-utils";
import { ClickObserver, Matcher, ModelLivePosition } from "@ckeditor/ckeditor5-engine";
import { upperFirst } from "es-toolkit/compat";
import { IconLink, IconNextArrow, IconPencil, IconPreviousArrow, IconSettings, IconUnlink } from "@ckeditor/ckeditor5-icons";
import { ButtonView, ContextualBalloon, CssTransitionDisablerMixin, FocusCycler, FormHeaderView, FormRowView, IconView, LabeledFieldView, ListItemView, ListView, MenuBarMenuListItemButtonView, SwitchButtonView, ToolbarView, View, ViewCollection, clickOutsideHandler, createLabeledInputText, submitHandler } from "@ckeditor/ckeditor5-ui";
import { isWidget } from "@ckeditor/ckeditor5-widget";
import { ImageBlockEditing, ImageEditing, ImageUtils } from "@ckeditor/ckeditor5-image";
/**
* @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/automaticdecorators
*/
/**
* 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.
*/
var AutomaticLinkDecorators = class {
/**
* 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 = /* @__PURE__ */ 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;
if (data.attributeKey == "linkHref" && !conversionApi.consumable.test(data.item, "attribute:linkHref")) return;
if (!data.item.is("selection") && !conversionApi.schema.isInline(data.item)) return;
for (const decorator of this._definitions) 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 });
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"));
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) 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 });
dispatcher.on("attribute", createConverter(true), { priority: priorities.high - 2 });
};
}
};
/**
* @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
*/
const ATTRIBUTE_WHITESPACES = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g;
const SAFE_URL_TEMPLATE = "^(?:(?:<protocols>):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))";
const EMAIL_REG_EXP = /^[\S]+@((?![-_])(?:[-\w\u00a1-\uffff]{0,63}[^-_]\.))+(?:[a-z\u00a1-\uffff]{2,})$/i;
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 }) {
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("|");
return isSafeUrl(urlString, new RegExp(`${SAFE_URL_TEMPLATE.replace("<protocols>", protocolsList)}`, "i")) ? urlString : "#";
}
/**
* Checks whether the given URL is safe for the user (does not contain any malicious code).
*/
function isSafeUrl(url, customRegexp) {
return !!url.replace(ATTRIBUTE_WHITESPACES, "").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) {
if (Object.keys(a.attributes).some((key) => !isMergeableAttribute(key) && key in b.attributes)) return true;
}
if (a.styles && b.styles) {
if (Object.keys(a.styles).some((key) => key in b.styles)) return true;
}
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);
}
/**
* @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/linkcommand
*/
/**
* The link command. It is used by the {@link module:link/link~Link link feature}.
*/
var LinkCommand = class 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());
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;
const resolvedDecoratorsIds = resolveConflictingDecorators({
allDecorators: Array.from(this.manualDecorators),
decoratorStates: manualDecoratorIds
});
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;
let newText = displayedText;
if (!newText) newText = linkHref && linkHref == linkText ? href : linkText;
if (newText != linkText) {
const fragment = writer.createDocumentFragment();
for (const item of range.getItems()) {
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));
const attributes = getLinkPartTextNode(subRange, fragRange).getAttributes();
const formattingAttributes = Array.from(attributes).filter(([key]) => model.schema.getAttributeProperties(key).isFormatting);
const newTextNode = writer.createText(expected, formattingAttributes);
updateLinkAttributes(newTextNode);
writer.remove(subRange);
writer.insert(newTextNode, subRange.start);
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")) plugins.get("TwoStepCaretMovement")._handleForwardMovement();
else for (const key of [
"linkHref",
...truthyManualDecorators,
...falsyManualDecorators
]) writer.removeSelectionAttribute(key);
};
if (selection.isCollapsed) {
const position = selection.getFirstPosition();
if (selection.hasAttribute("linkHref")) {
const linkHref = selection.getAttribute("linkHref");
const linkRange = findAttributeRange(position, "linkHref", linkHref, model);
const newLinkRange = updateLinkTextIfNeeded(linkRange, linkHref);
updateLinkAttributes(newLinkRange || linkRange);
if (newLinkRange) collapseSelectionAtLinkEnd(newLinkRange);
} else if (href !== "") {
const attributes = toMap(selection.getAttributes());
attributes.set("linkHref", href);
truthyManualDecorators.forEach((item) => {
attributes.set(item, true);
});
collapseSelectionAtLinkEnd(model.insertContent(writer.createText(displayedText || href, attributes), position));
}
} else {
const selectionRanges = Array.from(selection.getRanges());
const ranges = model.schema.getValidRanges(selectionRanges, "linkHref");
const allowedRanges = [];
for (const element of selection.getSelectedBlocks()) if (model.schema.checkAttribute(element, "linkHref")) allowedRanges.push(writer.createRangeOn(element));
const rangesToUpdate = allowedRanges.slice();
for (const range of ranges) if (this._isRangeToUpdate(range, allowedRanges)) rangesToUpdate.push(range);
const stickyPseudoRanges = selectionRanges.map((range) => ({
start: ModelLivePosition.fromPosition(range.start, "toPrevious"),
end: ModelLivePosition.fromPosition(range.end, "toNext")
}));
for (let range of rangesToUpdate) {
const linkHref = (range.start.textNode || range.start.nodeAfter).getAttribute("linkHref");
range = updateLinkTextIfNeeded(range, linkHref) || range;
updateLinkAttributes(range);
}
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();
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) 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) {
const changes = diff(oldText, newText);
const counter = {
equal: 0,
insert: 0,
delete: 0
};
const result = [];
let actualSlice = "";
let expectedSlice = "";
for (const action of [...changes, null]) {
if (action == "insert") expectedSlice += newText[counter.equal + counter.insert];
else if (action == "delete") actualSlice += oldText[counter.equal + counter.delete];
else if (actualSlice.length || expectedSlice.length) {
result.push({
offset: counter.equal,
actual: actualSlice,
expected: expectedSlice
});
actualSlice = "";
expectedSlice = "";
}
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 (!position.nodeBefore || position.isEqual(linkRange.start)) return position.nodeAfter;
else return position.nodeBefore;
}
/**
* @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/unlinkcommand
*/
/**
* The unlink command. It is used by the {@link module:link/link~Link link plugin}.
*/
var UnlinkCommand = class extends Command {
/**
* @inheritDoc
*/
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const selectedElement = selection.getSelectedElement();
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) => {
const rangesToUnlink = selection.isCollapsed ? [findAttributeRange(selection.getFirstPosition(), "linkHref", selection.getAttribute("linkHref"), model)] : model.schema.getValidRanges(selection.getRanges(), "linkHref");
for (const range of rangesToUnlink) {
writer.removeAttribute("linkHref", range);
if (linkCommand) for (const manualDecorator of linkCommand.manualDecorators) writer.removeAttribute(manualDecorator.id, range);
}
});
}
};
/**
* @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/manualdecorator
*/
const LinkManualDecoratorBase = /* #__PURE__ */ ObservableMixin();
/**
* 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}.
*/
var LinkManualDecorator = class extends LinkManualDecoratorBase {
/**
* 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", void 0);
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
};
}
};
/**
* @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/linkediting
*/
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.
*/
var LinkEditing = class 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() {
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");
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")
}
});
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));
editor.plugins.get(TwoStepCaretMovement).registerAttribute("linkHref");
inlineHighlight(editor, "linkHref", "a", HIGHLIGHT_CLASS);
this._enableLinkOpen();
this._enableSelectionAttributesFixer();
this._enableClipboardIntegration();
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;
const command = editor.commands.get("link");
const automaticDecorators = command.automaticDecorators;
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 (!modelItem.hasAttribute(manualDecorator.id)) continue;
if (areDecoratorsConflicting(automaticDecorator, manualDecorator)) return true;
}
});
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 manualDecorators = editor.commands.get("link").manualDecorators;
manualDecoratorDefinitions.forEach((decoratorDefinition) => {
editor.model.schema.extend("$text", { allowAttributes: decoratorDefinition.id });
const decorator = new LinkManualDecorator(decoratorDefinition);
manualDecorators.add(decorator);
editor.conversion.for("downcast").add((dispatcher) => {
const elementCreator = (writer) => {
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;
};
const createConverter = (isApplyingConverter) => {
return (evt, data, conversionApi) => {
if (!data.item.is("selection") && !conversionApi.schema.isInline(data.item)) return;
if (!isApplyingConverter && data.attributeOldValue) {
if (!conversionApi.consumable.test(data.item, evt.name)) return;
conversionApi.writer.unwrap(conversionApi.mapper.toViewRange(data.range), elementCreator(conversionApi.writer));
}
if (isApplyingConverter && data.attributeNewValue) {
if (!conversionApi.consumable.consume(data.item, evt.name)) return;
if (data.item.is("selection")) conversionApi.writer.wrap(conversionApi.writer.document.selection.getFirstRange(), elementCreator(conversionApi.writer));
else conversionApi.writer.wrap(conversionApi.mapper.toViewRange(data.range), elementCreator(conversionApi.writer));
}
};
};
dispatcher.on(`attribute:${decorator.id}`, createConverter(false), { priority: priorities.high - 1 });
dispatcher.on(`attribute:${decorator.id}`, createConverter(true), { priority: priorities.high - 2 });
});
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 viewDocument = editor.editing.view.document;
const handleLinkOpening = (url) => {
if (!this._linkOpeners.some((opener) => opener(url))) openLink(url);
};
this.listenTo(viewDocument, "click", (evt, data) => {
if (!(env.isMac ? data.domEvent.metaKey : data.domEvent.ctrlKey)) 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" });
this.listenTo(viewDocument, "keydown", (evt, data) => {
const url = editor.commands.get("link").value;
if (!(!!url && data.keyCode === keyCodes.enter && data.altKey)) 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 model = this.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);
}
});
});
}
/**
* Registers a postfixer that resolves conflicting decorator attributes on elements.
*/
_enableDecoratorConflictPostfixer() {
const editor = this.editor;
const model = editor.model;
const linkCommand = editor.commands.get("link");
model.document.registerPostFixer((writer) => {
let hasChanged = false;
const changes = model.document.differ.getChanges();
const elementsToCheck = /* @__PURE__ */ new Set();
const manualDecoratorAttributeKeys = new Set(linkCommand.manualDecorators.map((decorator) => decorator.id));
for (const change of changes) {
if (change.type === "attribute") {
if (change.attributeKey !== "linkHref" && !manualDecoratorAttributeKeys.has(change.attributeKey)) continue;
for (const item of change.range.getItems()) if (item.hasAttribute("linkHref")) elementsToCheck.add(item);
}
if (change.type === "insert" && change.attributes.has("linkHref") && change.position.nodeAfter) elementsToCheck.add(change.position.nodeAfter);
}
for (const item of elementsToCheck) {
const appliedDecorators = [];
for (const manualDecorator of linkCommand.manualDecorators) {
if (!item.hasAttribute(manualDecorator.id)) continue;
for (let i = appliedDecorators.length - 1; i >= 0; i--) {
const appliedDecorator = appliedDecorators[i];
if (areDecoratorsConflicting(appliedDecorator, manualDecorator)) {
writer.removeAttribute(appliedDecorator.id, item);
appliedDecorators.splice(i, 1);
hasChanged = true;
}
}
appliedDecorators.push(manualDecorator);
}
}
return hasChanged;
});
}
};
/**
* 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) {
return schema.getDefinition("$text").allowAttributes.filter((attribute) => attribute.startsWith("link"));
}
/**
* @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/ui/linkpreviewbuttonview
*/
/**
* The link button class. Rendered as an `<a>` tag with link opening in a new tab.
*
* Provides a custom `navigate` cancelable event.
*/
var LinkPreviewButtonView = class extends ButtonView {
/**
* @inheritDoc
*/
constructor(locale) {
super(locale);
const bind = this.bindTemplate;
this.set({
href: void 0,
withText: true
});
this.extendTemplate({
attributes: {
class: ["ck-link-toolbar__preview"],
href: bind.to("href"),
target: "_blank",
rel: "noopener noreferrer"
},
on: { click: bind.to((evt) => {
if (this.href) {
const cancel = () => evt.preventDefault();
this.fire("navigate", this.href, cancel);
}
}) }
});
this.template.tag = "a";
}
};
/**
* @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/ui/linkformview
*/
/**
* The link form view.
*/
var LinkFormView = class extends View {
/**
* Tracks information about DOM focus in the form.
*/
focusTracker = new FocusTracker();
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
keystrokes = new KeystrokeHandler();
/**
* The Back button view displayed in the header.
*/
backButtonView;
/**
* The Save button view.
*/
saveButtonView;
/**
* A button that opens the {@link module:link/ui/linkpropertiesview~LinkPropertiesView} allowing the user to
* configure manual decorators for the link handled by this form.
*/
manualDecoratorsButtonView;
/**
* The "Displayed text" input view.
*/
displayedTextInputView;
/**
* The URL input view.
*/
urlInputView;
/**
* A collection of child views.
*/
children;
/**
* A collection of child views in the providers list.
*/
providersListChildren;
/**
* An array of form validators used by {@link #isValid}.
*/
_validators;
/**
* A collection of views that can be focused in the form.
*/
_focusables = new ViewCollection();
/**
* Helps cycling over {@link #_focusables} in the form.
*/
_focusCycler;
/**
* Creates an instance of the {@link module:link/ui/linkformview~LinkFormView} class.
*
* Also see {@link #render}.
*
* @param locale The localization services instance.
* @param validators Form validators used by {@link #isValid}.
*/
constructor(locale, validators) {
super(locale);
this._validators = validators;
this.backButtonView = this._createBackButton();
this.saveButtonView = this._createSaveButton();
this.manualDecoratorsButtonView = this._createManualDecoratorsButton();
this.displayedTextInputView = this._createDisplayedTextInput();
this.urlInputView = this._createUrlInput();
this.providersListChildren = this.createCollection();
this.children = this.createCollection([this._createHeaderView()]);
this._createFormChildren();
this.listenTo(this.providersListChildren, "add", () => {
this.stopListening(this.providersListChildren, "add");
this.children.add(this._createProvidersListView());
});
this._focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: "shift + tab",
focusNext: "tab"
}
});
this.setTemplate({
tag: "form",
attributes: {
class: [
"ck",
"ck-form",
"ck-link-form",
"ck-responsive-form"
],
tabindex: "-1"
},
children: this.children
});
}
/**
* @inheritDoc
*/
render() {
super.render();
submitHandler({ view: this });
[
this.urlInputView,
this.manualDecoratorsButtonView,
this.saveButtonView,
...this.providersListChildren,
this.backButtonView,
this.displayedTextInputView
].forEach((v) => {
this._focusables.add(v);
this.focusTracker.add(v.element);
});
this.keystrokes.listenTo(this.element);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Focuses the fist {@link #_focusables} in the form.
*/
focus() {
this._focusCycler.focusFirst();
}
/**
* Validates the form and returns `false` when some fields are invalid.
*/
isValid() {
this.resetFormStatus();
for (const validator of this._validators) {
const errorText = validator(this);
if (errorText) {
this.urlInputView.errorText = errorText;
return false;
}
}
return true;
}
/**
* Cleans up the supplementary error and information text of the {@link #urlInputView}
* bringing them back to the state when the form has been displayed for the first time.
*
* See {@link #isValid}.
*/
resetFormStatus() {
this.urlInputView.errorText = null;
}
/**
* Creates a back button view that cancels the form.
*/
_createBackButton() {
const t = this.locale.t;
const backButton = new ButtonView(this.locale);
backButton.set({
class: "ck-button-back",
label: t("Back"),
icon: IconPreviousArrow,
tooltip: true
});
backButton.delegate("execute").to(this, "cancel");
return backButton;
}
/**
* Creates a save button view that inserts the link.
*/
_createSaveButton() {
const t = this.locale.t;
const saveButton = new ButtonView(this.locale);
saveButton.set({
label: t("Insert"),
tooltip: false,
withText: true,
type: "submit",
class: "ck-button-action ck-button-bold"
});
return saveButton;
}
/**
* Creates a button that opens the link properties view.
*/
_createManualDecoratorsButton() {
const t = this.locale.t;
const button = new ButtonView(this.locale);
button.set({
label: t("Link properties"),
icon: IconSettings,
tooltip: true,
isVisible: false
});
button.delegate("execute").to(this, "showDecorators");
return button;
}
/**
* Creates a header view for the form.
*/
_createHeaderView() {
const t = this.locale.t;
const header = new FormHeaderView(this.locale, { label: t("Link") });
header.children.add(this.backButtonView, 0);
return header;
}
/**
* Creates a view for the providers list.
*/
_createProvidersListView() {
const providersListView = new ListView(this.locale);
providersListView.extendTemplate({ attributes: { class: ["ck-link-form__providers-list"] } });
providersListView.items.bindTo(this.providersListChildren).using((def) => {
const listItemView = new ListItemView(this.locale);
listItemView.children.add(def);
return listItemView;
});
return providersListView;
}
/**
* Creates a labeled input view for the "Displayed text" field.
*/
_createDisplayedTextInput() {
const t = this.locale.t;
const labeledInput = new LabeledFieldView(this.locale, createLabeledInputText);
labeledInput.label = t("Displayed text");
labeledInput.class = "ck-labeled-field-view_full-width";
return labeledInput;
}
/**
* Creates a labeled input view for the URL field.
*
* @returns Labeled field view instance.
*/
_createUrlInput() {
const t = this.locale.t;
const labeledInput = new LabeledFieldView(this.locale, createLabeledInputText);
labeledInput.fieldView.inputMode = "url";
labeledInput.label = t("Link URL");
labeledInput.class = "ck-labeled-field-view_full-width";
return labeledInput;
}
/**
* Populates the {@link #children} collection of the form.
*/
_createFormChildren() {
this.children.add(new FormRowView(this.locale, {
children: [this.displayedTextInputView],
class: ["ck-form__row_large-top-padding"]
}));
this.children.add(new FormRowView(this.locale, {
children: [
this.urlInputView,
this.manualDecoratorsButtonView,
this.saveButtonView
],
class: [
"ck-form__row_with-submit",
"ck-form__row_large-top-padding",
"ck-form__row_large-bottom-padding"
]
}));
}
/**
* The native DOM `value` of the {@link #urlInputView} element.
*
* **Note**: Do not confuse it with the {@link module:ui/inputtext/inputtextview~InputTextView#value}
* which works one way only and may not represent the actual state of the component in the DOM.
*/
get url() {
const { element } = this.urlInputView.fieldView;
if (!element) return null;
return element.value.trim();
}
};
/**
* @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/ui/linkprovideritemsview
*/
/**
* The link provider items view.
*/
var LinkProviderItemsView = class extends View {
/**
* Tracks information about DOM focus in the form.
*/
focusTracker = new FocusTracker();
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
keystrokes = new KeystrokeHandler();
/**
* The Back button view displayed in the header.
*/
backButtonView;
/**
* The List view of links buttons.
*/
listView;
/**
* The collection of child views, which is bind with the `listView`.
*/
listChildren;
/**
* The view displayed when the list is empty.
*/
emptyListInformation;
/**
* A collection of child views.
*/
children;
/**
* A collection of views that can be focused in the form.
*/
_focusables = new ViewCollection();
/**
* Helps cycling over {@link #_focusables} in the form.
*/
_focusCycler;
/**
* Creates an instance of the {@link module:link/ui/linkprovideritemsview~LinkProviderItemsView} class.
*
* Also see {@link #render}.
*
* @param locale The localization services instance.
*/
constructor(locale) {
super(locale);
this.listChildren = this.createCollection();
this.backButtonView = this._createBackButton();
this.listView = this._createListView();
this.emptyListInformation = this._createEmptyLinksListItemView();
this.children = this.createCollection([this._createHeaderView(), this.emptyListInformation]);
this.set("title", "");
this.set("emptyListPlaceholder", "");
this.set("hasItems", false);
this.listenTo(this.listChildren, "change", () => {
this.hasItems = this.listChildren.length > 0;
});
this.on("change:hasItems", (evt, propName, hasItems) => {
if (hasItems) {
this.children.remove(this.emptyListInformation);
this.children.add(this.listView);
} else {
this.children.remove(this.listView);
this.children.add(this.emptyListInformation);
}
});
this.keystrokes.set("Esc", (data, cancel) => {
this.fire("cancel");
cancel();
});
this._focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: "shift + tab",
focusNext: "tab"
}
});
this.setTemplate({
tag: "div",
attributes: {
class: ["ck", "ck-link-providers"],
tabindex: "-1"
},
children: this.children
});
}
/**
* @inheritDoc
*/
render() {
super.render();
[this.listView, this.backButtonView].forEach((v) => {
this._focusables.add(v);
this.focusTracker.add(v.element);
});
this.keystrokes.listenTo(this.element);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Focuses the fist {@link #_focusables} in the form.
*/
focus() {
this._focusCycler.focusFirst();
}
/**
* Creates a view for the list at the bottom.
*/
_createListView() {
const listView = new ListView(this.locale);
listView.extendTemplate({ attributes: { class: ["ck-link-providers__list"] } });
listView.items.bindTo(this.listChildren).using((button) => {
const listItemView = new ListItemView(this.locale);
listItemView.children.add(button);
return listItemView;
});
return listView;
}
/**
* Creates a back button view that cancels the form.
*/
_createBackButton() {
const t = this.locale.t;
const backButton = new ButtonView(this.locale);
backButton.set({
class: "ck-button-back",
label: t("Back"),
icon: IconPreviousArrow,
tooltip: true
});
backButton.delegate("execute").to(this, "cancel");
return backButton;
}
/**
* Creates a header view for the form.
*/
_createHeaderView() {
const header = new FormHeaderView(this.locale);
header.bind("label").to(this, "title");
header.children.add(this.backButtonView, 0);
return header;
}
/**
* Creates an info view for an empty list.
*/
_createEmptyLinksListItemView() {
const view = new View(this.locale);
view.setTemplate({
tag: "p",
attributes: { class: ["ck", "ck-link__empty-list-info"] },
children: [{ text: this.bindTemplate.to("emptyListPlaceholder") }]
});
return view;
}
};
/**
* @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/ui/linkpropertiesview
*/
/**
* The link properties view controller class.
*
* See {@link module:link/ui/linkpropertiesview~LinkPropertiesView}.
*/
var LinkPropertiesView = class extends View {
/**
* Tracks information about DOM focus in the form.
*/
focusTracker = new FocusTracker();
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
keystrokes = new KeystrokeHandler();
/**
* The Back button view displayed in the header.
*/
backButtonView;
/**
* A collection of child views.
*/
children;
/**
* A collection of {@link module:ui/button/switchbuttonview~SwitchButtonView},
* which corresponds to {@link module:link/linkcommand~LinkCommand#manualDecorators manual decorators}
* configured in the editor.
*/
listChildren;
/**
* A collection of views that can be focused in the form.
*/
_focusables = new ViewCollection();
/**
* Helps cycling over {@link #_focusables} in the form.
*/
_focusCycler;
/**
* Creates an instance of the {@link module:link/ui/linkpropertiesview~LinkPropertiesView} class.
*
* Also see {@link #render}.
*
* @param locale The localization services instance.
*/
constructor(locale) {
super(locale);
this.backButtonView = this._createBackButton();
this.listChildren = this.createCollection();
this.children = this.createCollection([this._createHeaderView(), this._createListView()]);
this._focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: "shift + tab",
focusNext: "tab"
}
});
this.setTemplate({
tag: "div",
attributes: {
class: ["ck", "ck-link-properties"],
tabindex: "-1"
},
children: this.children
});
this.keystrokes.set("Esc", (data, cancel) => {
this.fire("back");
cancel();
});
}
/**
* @inheritDoc
*/
render() {
super.render();
[...this.listChildren, this.backButtonView].forEach((v) => {
this._focusables.add(v);
this.focusTracker.add(v.element);
});
this.keystrokes.listenTo(this.element);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Focuses the fist {@link #_focusables} in the form.
*/
focus() {
this._focusCycler.focusFirst();
}
/**
* Creates a back button view.
*/
_createBackButton() {
const t = this.locale.t;
const backButton = new ButtonView(this.locale);
backButton.set({
class: "ck-button-back",
label: t("Back"),
icon: IconPreviousArrow,
tooltip: true
});
backButton.delegate("execute").to(this, "back");
return backButton;
}
/**
* Creates a header view for the form.
*/
_createHeaderView() {
const t = this.locale.t;
const header = new FormHeaderView(this.locale, { label: t("Link properties") });
header.children.add(this.backButtonView, 0);
return header;
}
/**
* Creates a form view that displays the {@link #listChildren} collection.
*/
_createListView() {
const listView = new ListView(this.locale);
listView.extendTemplate({ attributes: { class: ["ck-link__list"] } });
listView.items.bindTo(this.listChildren).using((item) => {
const listItemView = new ListItemView(this.locale);
listItemView.children.add(item);
return listItemView;
});
return listView;
}
};
/**
* @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/ui/linkbuttonview
*/
/**
* Represents a view for a dropdown menu button.
*/
var LinkButtonView = class extends ButtonView {
/**
* An icon that displays an arrow to indicate a direction of the menu.
*/
arrowView;
/**
* Creates an instance of the dropdown menu button view.
*
* @param locale The localization services instance.
*/
constructor(locale) {
super(locale);
this.set({ withText: true });
this.arrowView = this._createArrowView();
this.extendTemplate({ attributes: { class: ["ck-link__button"] } });
}
/**
* @inheritDoc
*/
render() {
super.render();
this.children.add(this.arrowView);
}
/**
* Creates the arrow view instance.
*
* @private
*/
_createArrowView() {
const arrowView = new IconView();
arrowView.content = IconNextArrow;
return arrowView;
}
};
/**
* @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/linkui
*/
const VISUAL_SELECTION_MARKER_NAME = "link-ui";
/**
* The link UI plugin. It introduces the `'link'` and `'unlink'` buttons and support for the <kbd>Ctrl+K</kbd> keystroke.
*
* It uses the
* {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
*/
var LinkUI = class extends Plugin {
/**
* The toolbar view displayed inside of the balloon.
*/
toolbarView = null;
/**
* The form view displayed inside the balloon.
*/
formView = null;
/**
* The view displaying links list.
*/
linkProviderItemsView = null;
/**
* The form view displaying properties link settings.
*/
propertiesView = null;
/**
* The contextual balloon plugin instance.
*/
_balloon;
/**
* The collection of the link providers.
*/
_linksProviders = new Collection();
/**
* @inheritDoc
*/
static get requires() {
return [ContextualBalloon, LinkEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "LinkUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const t = this.editor.t;
this.set("selectedLinkableText", void 0);
editor.editing.view.addObserver(ClickObserver);
this._balloon = editor.plugins.get(ContextualBalloon);
this._registerComponents();
this._registerEditingOpeners();
this._enableBalloonActivators();
editor.conversion.for("editingDowncast").markerToHighlight({
model: VISUAL_SELECTION_MARKER_NAME,
view: { classes: ["ck-fake-link-selection"] }
});
editor.conversion.for("editingDowncast").markerToElement({
model: VISUAL_SELECTION_MARKER_NAME,
view: (data, { writer }) => {
if (!data.markerRange.isCollapsed) return null;
const markerElement = writer.createUIElement("span");
writer.addClass(["ck-fake-link-selection", "ck-fake-link-selection_collapsed"], markerElement);
return markerElement;
}
});
editor.accessibility.addKeystrokeInfos({ keystrokes: [{
label: t("Create link"),
keystroke: LINK_KEYSTROKE
}, {
label: t("Move out of a link"),
keystroke: [["arrowleft", "arrowleft"], ["arrowright", "arrowright"]]
}] });
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
if (this.propertiesView) this.propertiesView.destroy();
if (this.formView) this.formView.destroy();
if (this.toolbarView) this.toolbarView.destroy();
if (this.linkProviderItemsView) this.linkProviderItemsView.destroy();
}
/**
* Registers list of buttons below the link form view that
* open a list of links provided by the clicked provider.
*/
registerLinksListProvider(provider) {
const insertIndex = this._linksProviders.filter((existing) => (existing.order || 0) <= (provider.order || 0)).length;
this._linksProviders.add(provider, insertIndex);
}
/**
* Creates views.
*/
_createViews() {
const linkCommand = this.editor.commands.get("link");
this.toolbarView = this._createToolbarView();
this.formView = this._createFormView();
if (linkCommand.manualDecorators.length) this.propertiesView = this._createPropertiesView();
this._enableUserBalloonInteractions();
}
/**
* Creates the ToolbarView instance.
*/
_createToolbarView() {
const editor = this.editor;
const toolbarView = new ToolbarView(editor.locale);
const linkCommand = editor.commands.get("link");
toolbarView.class = "ck-link-toolbar";
let toolbarItems = editor.config.get("link.toolbar");
if (!linkCommand.manualDecorators.length) toolbarItems = toolbarItems.filter((item) => item !== "linkProperties");
toolbarView.fillFromConfig(toolbarItems, editor.ui.componentFactory);
toolbarView.keystrokes.set("Esc", (data, cancel) => {
this._hideUI();
cancel();
});
toolbarView.keystrokes.set(LINK_KEYSTROKE, (data, cancel) => {
this._addFormView();
cancel();
});
editor.ui.addToolbar(toolbarView, {
isContextual: true,
beforeFocus: () => {
if (this._getSelectedLinkElement() && !this._isToolbarVisible) this._showUI(true);
},
afterBlur: () => {
this._hideUI(false);
}
});
return toolbarView;
}
/**
* Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
*/
_createFormView() {
const editor = this.editor;
const t = editor.locale.t;
const linkCommand = editor.commands.get("link");
const defaultProtocol = editor.config.get("link.defaultProtocol");
const formView = new (CssTransitionDisablerMixin(LinkFormView))(editor.locale, getFormValidators(editor));
formView.displayedTextInputView.bind("isEnabled").to(this, "selectedLinkableText", (value) => value !== void 0);
formView.urlInputView.bind("isEnabled").to(linkCommand, "isEnabled");
formView.saveButtonView.bind("isEnabled").to(linkCommand, "isEnabled");
formView.saveButtonView.bind("label").to(linkCommand, "value", (value) => value ? t("Update") : t("Insert"));
this.listenTo(formView, "submit", () => {
if (formView.isValid()) {
const url = formView.urlInputView.fieldView.element.value;
const parsedUrl = addLinkProtocolIfApplicable(url, defaultProtocol);
const displayedText = formView.displayedTextInputView.fieldView.element.value;
editor.execute("link", parsedUrl, this._getDecoratorSwitchesState(), displayedText !== this.selectedLinkableText ? displayedText : void 0);
this._closeFormView();
}
});
this.listenTo(formView.urlInputView, "change:errorText", () => {
editor.ui.update();
});
this.listenTo(formView, "cancel", () => {
this._closeFormView();
});
formView.keystrokes.set("Esc", (data, cancel) => {
this._closeFormView();
cancel();
});
formView.providersListChildren.bindTo(this._linksProviders).using((provider) => this._createLinksListProviderButton(provider));
formView.manualDecoratorsButtonView.isVisible = linkCommand.manualDecorators.length > 0;
this.listenTo(formView, "showDecorators", () => {
this._addPropertiesView();
});
return formView;
}
/**
* Creates a sorted array of buttons with link names.
*/
_createLinkProviderListView(provider) {
return provider.getListItems().map(({ href, label, icon }) => {
const buttonView = new ButtonView();
buttonView.set({
label,
icon,
tooltip: false,
withText: true
});
buttonView.on("execute", () => {
this.formView.resetFormStatus();
this.formView.urlInputView.fieldView.value = href;
this.editor.editing.view.focus();
this._removeLinksProviderView();
this.formView.focus();
});
return buttonView;
});
}
/**
* Creates a view for links provider.
*/
_createLinkProviderItemsView(provider) {
const editor = this.editor;
const t = editor.locale.t;
const view = new LinkProviderItemsView(editor.locale);
const { emptyListPlaceholder, label } = provider;
view.emptyListPlaceholder = emptyListPlaceholder || t("No links available");
view.title = label;
this.listenTo(view, "cancel", () => {
editor.editing.view.focus();
this._removeLinksProviderView();
this.formView.focus();
});
return view;
}
/**
* Creates the {@link module:link/ui/linkpropertiesview~LinkPropertiesView} instance.
*/
_createPropertiesView() {
const editor = this.editor;
const linkCommand = this.editor.commands.get("link");
const view = new (CssTransitionDisablerMixin(LinkPropertiesView))(editor.locale);
this.listenTo(view, "back", () => {
if (this._isFormInPanel) {
this._removePropertiesView();
this.formView.focus();
} else {
editor.editing.view.focus();
this._removePropertiesView();
}
});
view.listChildren.bindTo(linkCommand.manualDecorators).using((manualDecorator) => {
const button = new SwitchButtonView(editor.locale);
button.set({
label: manualDecorator.label,
withText: true
});
button.bind("isOn").toMany([manualDecorator, linkCommand], "value", (decoratorValue, commandValue) => {
return commandValue === void 0 && decoratorValue === void 0 ? !!manualDecorator.defaultValue : !!decoratorValue;
});
button.on("execute", () => {
const newValue = !button.isOn;
if (linkCommand.value !== void 0) editor.execute("link", linkCommand.value, {
...this._getDecoratorSwitchesState(),
[manualDecorator.id]: newValue
});
else this._togglePendingManualDecorator(manualDecorator, newValue);
});
return button;
});
return view;
}
/**
* Obtains the state of the manual decorators.
*/
_getDecoratorSwitchesState() {
const linkCommand = this.editor.commands.get("link");
return Array.from(linkCommand.manualDecorators).reduce((accumulator, manualDecorator) => {
const value = linkCommand.value === void 0 && manualDecorator.value === void 0 ? manualDecorator.defaultValue : manualDecorator.value;
return {
...accumulator,
[manualDecorator.id]: !!value
};
}, {});
}
/**
* Toggles the state of a manual decorator while a new link is being created, that is, before it is
* inserted into the document. Since there is no link (and no command value) to execute the `'link'`
* command against yet, the new state is kept on the decorator's own observable
* {@link module:link/utils/manualdecorator~LinkManualDecorator#value} instead of the model.
*/
_togglePendingManualDecorator(manualDecorator, newValue) {
const linkCommand = this.editor.commands.get("link");
const allDecorators = Array.from(linkCommand.manualDecorators);
const decoratorStates = this._getDecoratorSwitchesState();
const previousDecoratorStates = allDecorators.map((decorator) => ({
id: decorator.id,
attributes: decorator.attributes,
styles: decorator.styles,
value: decoratorStates[decorator.id]
}));
decoratorStates[manualDecorator.id] = newValue;
const resolvedStates = resolveConflictingDecorators({
decoratorStates,
allDecorators: previousDecoratorStates
});
for (const decorator of allDecorators) decorator.value = resolvedStates[decorator.id];
}
/**
* Clears the state of all manual decorators that may have been toggled while creating a link that,
* in the end, was never inserted into the document (for example, the user canceled the form).
*/
_resetPendingManualDecoratorsState() {
const linkCommand = this.editor.commands.get("link");
for (const manualDecorator of linkCommand.manualDecorators) manualDecorator.value = void 0;
}
/**
* Registers listeners used in editing plugin, used to open links.
*/
_registerEditingOpeners() {
this.editor.plugins.get(LinkEditing)._registerLinkOpener((href) => {
const match = this._getLinkProviderLinkByHref(href);
if (!match) return false;
const { item, provider } = match;
if (provider.navigate) return provider.navigate(item);
return false;
});
}
/**
* Registers components in the ComponentFactory.
*/
_registerComponents() {
const editor = this.editor;
editor.ui.componentFactory.add("link", () => {
const button = this._createButton(ButtonView);
button.set({ tooltip: true });
return button;
});
editor.ui.componentFactory.add("menuBar:link", () => {
const button = this._createButton(MenuBarMenuListItemButtonView);
button.set({ role: "menuitemcheckbox" });
return button;
});
editor.ui.componentFactory.add("linkPreview", (locale) => {
const button = new LinkPreviewButtonView(locale);
const allowedProtocols = editor.config.get("link.allowedProtocols");
const linkCommand = editor.commands.get("link");
const t = locale.t;
button.bind("isEnabled").to(linkCommand, "value", (href) => !!href);
button.bind("href").to(linkCommand, "value", (href) => {
return href && ensureSafeUrl(href, allowedProtocols);
});
const setHref = (href) => {
if (href === "") {
button.label = t("This link has no URL");
button.icon = void 0;
button.tooltip = false;
return;
}
if (!href) {
button.label = void 0;
button.icon = void 0;
button.tooltip = t("Open link in new tab");
return;
}
const selectedLinksProviderLink = this._getLinkProviderLinkByHref(href);
if (selectedLinksProviderLink) {
const { label, tooltip, icon } = selectedLinksProviderLink.item;
button.label = label;
button.tooltip = tooltip || false;
button.icon = icon;
} else {
button.label = href;
button.icon = void 0;
button.tooltip = t("Open link in new tab");
}
};
setHref(linkCommand.value);
this.listenTo(linkCommand, "change:value", (evt, name, href) => {
setHref(href);
});
this.listenTo(button, "navigate", (evt, href, cancel) => {
const selectedLinksProviderLink = this._getLinkProviderLinkByHref(href);
if (!selectedLinksProviderLink) return;
const { provider, item } = selectedLinksProviderLink;
const { navigate } = provider;
if (navigate && navigate(item)) {
evt.stop();
cancel();
}
});
return button;
});
editor.ui.componentFactory.add("unlink", (locale) => {
const unlinkCommand = editor.commands.get("unlink");
const button = new ButtonView(locale);
const t = locale.t;
button.set({
label: t("Unlink"),
icon: IconUnlink,
tooltip: true
});
button.bind("isEnabled").to(unlinkCommand);
this.listenTo(button, "execute", () => {
editor.execute("unlink");
this._hideUI();
});
return button;
});
editor.ui.componentFactory.add("editLink", (locale) => {
const linkCommand = editor.commands.get("link");
const button = new ButtonView(locale);
const t = locale.t;
button.set({
label: t("Edit link"),
icon: IconPencil,
tooltip: true
});
button.bind("isEnabled").to(linkCommand);
this.listenTo(button, "execute", () => {
this._addFormView();
});
return button;
});
editor.ui.componentFactory.add("linkProperties", (locale) => {
const linkCommand = editor.commands.get("link");
const button = new ButtonView(locale);
const t = locale.t;
button.set({
label: t("Link properties"),
icon: IconSettings,
tooltip: true
});
button.bind("isEnabled").to(linkCommand, "isEnabled", linkCommand, "value", linkCommand, "manualDecorators", (isEnabled, href, manualDecorators) => isEnabled && href !== void 0 && manualDecorators.length > 0);
this.listenTo(button, "execute", () => {
this._addPropertiesView();
});
return button;
});
}
/**
* Creates a links button view.
*/
_createLinksListProviderButton(linkProvider) {
const locale = this.editor.locale;
const linksButton = new LinkButtonView(locale);
linksButton.set({ label: linkProvider.label });
this.listenTo(linksButton, "execute", () => {
this._showLinksProviderView(linkProvider);
});
return linksButton;
}
/**
* Creates a button for link command to use either in toolbar or in menu bar.
*/
_createButton(ButtonClass) {
const editor = this.editor;
const locale = editor.locale;
const command = editor.commands.get("link");
const view = new ButtonClass(editor.locale);
const t = locale.t;
view.set({
label: t("Link"),
icon: IconLink,
keystroke: LINK_KEYSTROKE,
isToggleable: true
});
view.bind("isEnabled").to(command, "isEnabled");
view.bind("isOn").to(command, "value", (value) => !!value);
this.listenTo(view, "execute", () => {
editor.editing.view.scrollToTheSelection();
this._showUI(true);
if (this._getSelectedLinkElement()) this._addFormView();
});
return view;
}
/**
* Attaches actions that control whether the balloon panel containing the
* {@link #formView} should be displayed.
*/
_enableBalloonActivators() {
const editor = this.editor;
const viewDocument = editor.editing.view.document;
this.listenTo(viewDocument, "click", () => {
if (this._getSelectedLinkElement()) this._showUI();
});
editor.keystrokes.set(LINK_KEYSTROKE, (keyEvtData, cancel) => {
cancel();
if (editor.commands.get("link").isEnabled) {
editor.editing.view.scrollToTheSelection();
this._showUI(true);
}
});
}
/**
* Attaches actions that control whether the balloon panel containing the
* {@link #formView} is visible or not.
*/
_enableUserBalloonInteractions() {
this.editor.keystrokes.set("Tab", (data, cancel) => {
if (this._isToolbarVisible && !this.toolbarView.focusTracker.isFocused) {
this.toolbarView.focus();
cancel();
}
}, { priority: "high" });
this.editor.keystrokes.set("Esc", (data, cancel) => {
if (this._isUIVisible) {
this._hideUI();
cancel();
}
});
clickOutsideHandler({
emitter: this.formView,
activator: () => this._isUIInPanel,
contextElements: () => [this._balloon.view.element],
callback: () => {
this._hideUI(false);
}
});
}
/**
* Adds the {@link #toolbarView} to the {@link #_balloon}.
*
* @internal
*/
_addToolbarView() {
if (!this.toolbarView) this._createViews();
if (this._isToolbarInPanel) return;
this._balloon.add({
view: this.toolbarView,
position: this._getBalloonPositionData(),
balloonClassName: "ck-toolbar-container"
});
}
/**
* Adds the {@link #formView} to the {@link #_balloon}.
*/
_addFormView() {
if (!this.formView) this._createViews();
if (this._isFormInPanel) return;
const linkCommand = this.editor.commands.get("link");
this.formView.disableCssTransitions();
this.formView.resetFormStatus();
this.formView.backButtonView.isVisible = linkCommand.isEnabled && linkCommand.value !== void 0;
this._balloon.add({
view: this.formView,
position: this._getBalloonPositionData()
});
this.selectedLinkableText = this._getSelectedLinkableText();
this.formView.displayedTextInputView.fieldView.value = this.selectedLinkableText || "";
this.formView.urlInputView.fieldView.value = linkCommand.value || "";
if (this._balloon.visibleView === this.formView) this.formView.urlInputView.fieldView.select();
this.formView.enableCssTransitions();
}
/**
* Adds the {@link #propertiesView} to the {@link #_balloon}.
*/
_addPropertiesView() {
if (!this.propertiesView) this._createViews();
if (this._arePropertiesInPanel) return;
this.propertiesView.disableCssTransitions();
this._balloon.add({
view: this.propertiesView,
position: this._getBalloonPositionData()
});
this.propertiesView.enableCssTransitions();
this.propertiesView.focus();
}
/**
* Shows the view with links provided by the given provider.
*/
_showLinksProviderView(provider) {
if (this.linkProviderItemsView) this._removeLinksProviderView();
this.linkProviderItemsView = this._createLinkProviderItemsView(provider);
this._addLinkProviderItemsView(provider);
}
/**
* Adds the {@link #linkProviderItemsView} to the {@link #_balloon}.
*/
_addLinkProviderItemsView(provider) {
this.linkProviderItemsView.listChildren.clear();
this.linkProviderItemsView.listChildren.addMany(this._createLinkProviderListView(provider));
this._balloon.add({
view: this.linkProviderItemsView,
position: this._getBalloonPositionData()
});
this.linkProviderItemsView.focus();
}
/**
* Closes the form view. Decides whether the balloon should be hidden completely or if the action view should be shown. This is
* decided upon the link command value (which has a value if the document selection is in the link).
*/
_closeFormView() {
const linkCommand = this.editor.commands.get("link");
this.selectedLinkableText = void 0;
if (linkCommand.value !== void 0) this._removeFormView();
else this._hideUI();
}
/**
* Removes the {@link #propertiesView} from the {@link #_balloon}.
*/
_removePropertiesView() {
if (this._arePropertiesInPanel) this._balloon.remove(this.propertiesView);
}
/**
* Removes the {@link #linkProviderItemsView} from the {@link #_balloon}.
*/
_removeLinksProviderView() {
if (this._isLinksListInPanel) this._balloon.remove(this.linkProviderItemsView);
}
/**
* Removes the {@link #formView} from the {@link #_balloon}.
*/
_removeFormView(updateFocus = true) {
if (this._isFormInPanel) {
const linkCommand = this.editor.commands.get("link");
this.formView.saveButtonView.focus();
this.formView.displayedTextInputView.fieldView.reset();
this.formView.urlInputView.fieldView.reset();
if (linkCommand.value === void 0) this._resetPendingManualDecoratorsState();
this._balloon.remove(this.formView);
if (updateFocus) this.editor.editing.view.focus();
this._hideFakeVisualSelection();
}
}
/**
* Shows the correct UI type. It is either {@link #formView} or {@link #toolbarView}.
*
* @internal
*/
_showUI(forceVisible = false) {
if (!this.formView) this._createViews();
if (!this._getSelectedLinkElement()) {
this._showFakeVisualSelection();
this._addToolbarView();
if (forceVisible) this._balloon.showStack("main");
this._addFormView();
} else {
if (this._isToolbarVisible) this._addFormView();
else this._addToolbarView();
if (forceVisible) this._balloon.showStack("main");
}
this._startUpdatingUI();
}
/**
* Removes the {@link #formView} from the {@link #_balloon}.
*
* See {@link #_addFormView}, {@link #_addToolbarView}.
*/
_hideUI(updateFocus = true) {
const editor = this.editor;
if (!this._isUIInPanel) return;
this.stopListening(editor.ui, "update");
this.stopListening(this._balloon, "change:visibleView");
if (updateFocus) editor.editing.view.focus();
this._removeLinksProviderView();
this._removePropertiesView();
this._removeFormView(updateFocus);
if (this._isToolbarInPanel) this._balloon.remove(this.toolbarView);
this._hideFakeVisualSelection();
}
/**
* Makes the UI react to the {@link module:ui/editorui/editorui~EditorUI#event:update} event to
* reposition itself when the editor UI should be refreshed.
*
* See: {@link #_hideUI} to learn when the UI stops reacting to the `update` event.
*/
_startUpdatingUI() {
const editor = this.editor;
const viewDocument = editor.editing.view.document;
let prevSelectedLink = this._getSelectedLinkElement();
let prevSelectionParent = getSelectionParent();
const update = () => {
const selectedLink = this._getSelectedLinkElement();
const selectionParent = getSelectionParent();
if (prevSelectedLink && !selectedLink || !prevSelectedLink && selectionParent !== prevSelectionParent) this._hideUI();
else if (this._isUIVisible) this._balloon.updatePosition(this._getBalloonPositionData());
prevSelectedLink = selectedLink;
prevSelectionParent = selectionParent;
};
function getSelectionParent() {
return viewDocument.selection.focus.getAncestors().reverse().find((node) => node.is("element"));
}
this.listenTo(editor.ui, "update", update);
this.listenTo(this._balloon, "change:visibleView", update);
}
/**
* Returns `true` when {@link #propertiesView} is in the {@link #_balloon}.
*/
get _arePropertiesInPanel() {
return !!this.propertiesView && this._balloon.hasView(this.propertiesView);
}
/**
* Returns `true` when {@link #linkProviderItemsView} is in the {@link #_balloon}.
*/
get _isLinksListInPanel() {
return !!this.linkProviderItemsView && this._balloon.hasView(this.linkProviderItemsView);
}
/**
* Returns `true` when {@link #formView} is in the {@link #_balloon}.
*/
get _isFormInPanel() {
return !!this.formView && this._balloon.hasView(this.formView);
}
/**
* Returns `true` when {@link #toolbarView} is in the {@link #_balloon}.
*/
get _isToolbarInPanel() {
return !!this.toolbarView && this._balloon.hasView(this.toolbarView);
}
/**
* Returns `true` when {@link #propertiesView} is in the {@link #_balloon} and it is
* currently visible.
*/
get _isPropertiesVisible() {
return !!this.propertiesView && this._balloon.visibleView === this.propertiesView;
}
/**
* Returns `true` when {@link #formView} is in the {@link #_balloon} and it is
* currently visible.
*/
get _isFormVisible() {
return !!this.formView && this._balloon.visibleView == this.formView;
}
/**
* Returns `true` when {@link #toolbarView} is in the {@link #_balloon} and it is
* currently visible.
*/
get _isToolbarVisible() {
return !!this.toolbarView && this._balloon.visibleView === this.toolbarView;
}
/**
* Returns `true` when {@link #propertiesView}, {@link #toolbarView}, {@link #linkProviderItemsView}
* or {@link #formView} is in the {@link #_balloon}.
*/
get _isUIInPanel() {
return this._arePropertiesInPanel || this._isLinksListInPanel || this._isFormInPanel || this._isToolbarInPanel;
}
/**
* Returns `true` when {@link #propertiesView}, {@link #linkProviderItemsView}, {@link #toolbarView}
* or {@link #formView} is in the {@link #_balloon} and it is currently visible.
*/
get _isUIVisible() {
return this._isPropertiesVisible || this._isLinksListInPanel || this._isFormVisible || this._isToolbarVisible;
}
/**
* Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached
* to the target element or selection.
*
* If the selection is collapsed and inside a link element, the panel will be attached to the
* entire link element. Otherwise, it will be attached to the selection.
*/
_getBalloonPositionData() {
const view = this.editor.editing.view;
const viewDocument = view.document;
if (this.editor.model.markers.has(VISUAL_SELECTION_MARKER_NAME)) {
const markerViewElements = this.editor.editing.mapper.markerNameToElements(VISUAL_SELECTION_MARKER_NAME);
if (markerViewElements) {
const markerViewElementsArray = Array.from(markerViewElements);
const newRange = view.createRange(view.createPositionBefore(markerViewElementsArray[0]), view.createPositionAfter(markerViewElementsArray[markerViewElementsArray.length - 1]));
return { target: view.domConverter.viewRangeToDom(newRange) };
}
}
return { target: () => {
const targetLink = this._getSelectedLinkElement();
return targetLink ? view.domConverter.mapViewToDom(targetLink) : view.domConverter.viewRangeToDom(viewDocument.selection.getFirstRange());
} };
}
/**
* Returns the link {@link module:engine/view/attributeelement~ViewAttributeElement} under
* the {@link module:engine/view/document~ViewDocument editing view's} selection or `null`
* if there is none.
*
* **Note**: For a non–collapsed selection, the link element is returned when **fully**
* selected and the **only** element within the selection boundaries, or when
* a linked widget is selected.
*/
_getSelectedLinkElement() {
const view = this.editor.editing.view;
const selection = view.document.selection;
const selectedElement = selection.getSelectedElement();
if (selection.isCollapsed || selectedElement && isWidget(selectedElement)) return findLinkElementAncestor(selection.getFirstPosition());
else {
const range = selection.getFirstRange().getTrimmed();
const startLink = findLinkElementAncestor(range.start);
const endLink = findLinkElementAncestor(range.end);
if (!startLink || startLink != endLink) return null;
if (view.createRangeIn(startLink).getTrimmed().isEqual(range)) return startLink;
else return null;
}
}
/**
* Returns selected link text content.
* If link is not selected it returns the selected text.
* If selection or link includes non text node (inline object or block) then returns undefined.
*/
_getSelectedLinkableText() {
const model = this.editor.model;
const editing = this.editor.editing;
const selectedLink = this._getSelectedLinkElement();
if (!selectedLink) return extractTextFromLinkRange(model.document.selection.getFirstRange());
const viewLinkRange = editing.view.createRangeOn(selectedLink);
return extractTextFromLinkRange(editing.mapper.toModelRange(viewLinkRange));
}
/**
* Returns a provider by its URL.
*
* @param href URL of the link.
* @returns Link provider and item or `null` if not found.
*/
_getLinkProviderLinkByHref(href) {
if (!href) return null;
for (const provider of this._linksProviders) {
const item = provider.getItem ? provider.getItem(href) : provider.getListItems().find((item) => item.href === href);
if (item) return {
provider,
item
};
}
return null;
}
/**
* Displays a fake visual selection when the contextual balloon is displayed.
*
* This adds a 'link-ui' marker into the document that is rendered as a highlight on selected text fragment.
*/
_showFakeVisualSelection() {
const model = this.editor.model;
model.change((writer) => {
const range = model.document.selection.getFirstRange();
if (model.markers.has(VISUAL_SELECTION_MARKER_NAME)) writer.updateMarker(VISUAL_SELECTION_MARKER_NAME, { range });
else if (range.start.isAtEnd) {
const startPosition = range.start.getLastMatchingPosition(({ item }) => !model.schema.isContent(item), { boundaries: range });
writer.addMarker(VISUAL_SELECTION_MARKER_NAME, {
usingOperation: false,
affectsData: false,
range: writer.createRange(startPosition, range.end)
});
} else writer.addMarker(VISUAL_SELECTION_MARKER_NAME, {
usingOperation: false,
affectsData: false,
range
});
});
}
/**
* Hides the fake visual selection created in {@link #_showFakeVisualSelection}.
*/
_hideFakeVisualSelection() {
const model = this.editor.model;
if (model.markers.has(VISUAL_SELECTION_MARKER_NAME)) model.change((writer) => {
writer.removeMarker(VISUAL_SELECTION_MARKER_NAME);
});
}
};
/**
* Returns a link element if there's one among the ancestors of the provided `Position`.
*
* @param View position to analyze.
* @returns Link element at the position or null.
*/
function findLinkElementAncestor(position) {
return position.getAncestors().find((ancestor) => isLinkElement(ancestor)) || null;
}
/**
* Returns link form validation callbacks.
*
* @param editor Editor instance.
*/
function getFormValidators(editor) {
const t = editor.t;
const allowCreatingEmptyLinks = editor.config.get("link.allowCreatingEmptyLinks");
return [(form) => {
if (!allowCreatingEmptyLinks && !form.url.length) return t("Link URL must not be empty.");
}];
}
/**
* @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/autolink
*/
const MIN_LINK_LENGTH_WITH_SPACE_AT_END = 4;
const URL_REG_EXP = /* @__PURE__ */ new RegExp("(^|\\s)(((?:(?:(?:https?|ftp):)?\\/\\/)(?:\\S+(?::\\S*)?@)?(?:(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(((?!www\\.)|(www\\.))(?![-_])(?:[-_a-z0-9\\u00a1-\\uffff]{1,63}\\.)+(?:[a-z\\u00a1-\\uffff]{2,63}))|localhost)(?::\\d{2,5})?(?:[/?#]\\S*)?)|((www.|(\\S+@))((?![-_])(?:[-_a-z0-9\\u00a1-\\uffff]{1,63}\\.))+(?:[a-z\\u00a1-\\uffff]{2,63})))$", "i");
const URL_GROUP_IN_MATCH = 2;
/**
* The autolink plugin.
*/
var AutoLink = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [Delete, LinkEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "AutoLink";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const selection = this.editor.model.document.selection;
selection.on("change:range", () => {
this.isEnabled = !selection.anchor.parent.is("element", "codeBlock");
});
this._enableTypingHandling();
}
/**
* @inheritDoc
*/
afterInit() {
this._enableEnterHandling();
this._enableShiftEnterHandling();
this._enablePasteLinking();
}
/**
* For given position, returns a range that includes the whole link that contains the position.
*
* If position is not inside a link, returns `null`.
*/
_expandLinkRange(model, position) {
if (position.textNode && position.textNode.hasAttribute("linkHref")) return findAttributeRange(position, "linkHref", position.textNode.getAttribute("linkHref"), model);
else return null;
}
/**
* Extends the document selection to includes all links that intersects with given `selectedRange`.
*/
_selectEntireLinks(writer, selectedRange) {
const model = this.editor.model;
const selection = model.document.selection;
const selStart = selection.getFirstPosition();
const selEnd = selection.getLastPosition();
let updatedSelection = selectedRange.getJoined(this._expandLinkRange(model, selStart) || selectedRange);
if (updatedSelection) updatedSelection = updatedSelection.getJoined(this._expandLinkRange(model, selEnd) || selectedRange);
if (updatedSelection && (updatedSelection.start.isBefore(selStart) || updatedSelection.end.isAfter(selEnd))) writer.setSelection(updatedSelection);
}
/**
* Enables autolinking on pasting a URL when some content is selected.
*/
_enablePasteLinking() {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
const clipboardPipeline = editor.plugins.get("ClipboardPipeline");
const linkCommand = editor.commands.get("link");
clipboardPipeline.on("inputTransformation", (evt, data) => {
if (!this.isEnabled || !linkCommand.isEnabled || selection.isCollapsed || data.method !== "paste") return;
if (selection.rangeCount > 1) return;
const selectedRange = selection.getFirstRange();
const newLink = data.dataTransfer.getData("text/plain");
if (!newLink) return;
const matches = newLink.match(URL_REG_EXP);
if (matches && matches[2] === newLink) {
model.change((writer) => {
this._selectEntireLinks(writer, selectedRange);
linkCommand.execute(newLink);
});
evt.stop();
}
}, { priority: "high" });
}
/**
* Enables autolinking on typing.
*/
_enableTypingHandling() {
const editor = this.editor;
const watcher = new TextWatcher(editor.model, (text) => {
let mappedText = text;
if (!isSingleSpaceAtTheEnd(mappedText)) return;
mappedText = mappedText.slice(0, -1);
if ("!.:,;?".includes(mappedText[mappedText.length - 1])) mappedText = mappedText.slice(0, -1);
const url = getUrlAtTextEnd(mappedText);
if (url) return {
url,
removedTrailingCharacters: text.length - mappedText.length
};
});
watcher.on("matched:data", (evt, data) => {
const { batch, range, url, removedTrailingCharacters } = data;
if (!batch.isTyping) return;
const linkEnd = range.end.getShiftedBy(-removedTrailingCharacters);
const linkStart = linkEnd.getShiftedBy(-url.length);
const linkRange = editor.model.createRange(linkStart, linkEnd);
this._applyAutoLink(url, linkRange);
});
watcher.bind("isEnabled").to(this);
}
/**
* Enables autolinking on the <kbd>Enter</kbd> key.
*/
_enableEnterHandling() {
const editor = this.editor;
const model = editor.model;
const enterCommand = editor.commands.get("enter");
if (!enterCommand) return;
enterCommand.on("execute", () => {
const position = model.document.selection.getFirstPosition();
let rangeToCheck;
if (position.parent.previousSibling?.is("element")) rangeToCheck = model.createRangeIn(position.parent.previousSibling);
else rangeToCheck = model.createRange(model.createPositionAt(position.parent, 0), position);
this._checkAndApplyAutoLinkOnRange(rangeToCheck);
});
}
/**
* Enables autolinking on the <kbd>Shift</kbd>+<kbd>Enter</kbd> keyboard shortcut.
*/
_enableShiftEnterHandling() {
const editor = this.editor;
const model = editor.model;
const shiftEnterCommand = editor.commands.get("shiftEnter");
if (!shiftEnterCommand) return;
shiftEnterCommand.on("execute", () => {
const position = model.document.selection.getFirstPosition();
const rangeToCheck = model.createRange(model.createPositionAt(position.parent, 0), position.getShiftedBy(-1));
this._checkAndApplyAutoLinkOnRange(rangeToCheck);
});
}
/**
* Checks if the passed range contains a linkable text.
*/
_checkAndApplyAutoLinkOnRange(rangeToCheck) {
const model = this.editor.model;
const { text, range } = getLastTextLine(rangeToCheck, model);
const url = getUrlAtTextEnd(text);
if (url) {
const linkRange = model.createRange(range.end.getShiftedBy(-url.length), range.end);
this._applyAutoLink(url, linkRange);
}
}
/**
* Applies a link on a given range if the link should be applied.
*
* @param url The URL to link.
* @param range The text range to apply the link attribute to.
*/
_applyAutoLink(url, range) {
const model = this.editor.model;
const fullUrl = addLinkProtocolIfApplicable(url, this.editor.config.get("link.defaultProtocol"));
if (!this.isEnabled || !isLinkAllowedOnRange(range, model) || !linkHasProtocol(fullUrl) || linkIsAlreadySet(range)) return;
this._persistAutoLink(fullUrl, range);
}
/**
* Enqueues autolink changes in the model.
*
* @param url The URL to link.
* @param range The text range to apply the link attribute to.
*/
_persistAutoLink(url, range) {
const model = this.editor.model;
const deletePlugin = this.editor.plugins.get("Delete");
model.enqueueChange((writer) => {
writer.setAttribute("linkHref", url, range);
model.enqueueChange(() => {
deletePlugin.requestUndoOnBackspace();
});
});
}
};
function isSingleSpaceAtTheEnd(text) {
return text.length > MIN_LINK_LENGTH_WITH_SPACE_AT_END && text[text.length - 1] === " " && text[text.length - 2] !== " ";
}
function getUrlAtTextEnd(text) {
const match = URL_REG_EXP.exec(text);
return match ? match[URL_GROUP_IN_MATCH] : null;
}
function isLinkAllowedOnRange(range, model) {
return model.schema.checkAttributeInSelection(model.createSelection(range), "linkHref");
}
function linkIsAlreadySet(range) {
const item = range.start.nodeAfter;
return !!item && item.hasAttribute("linkHref");
}
/**
* @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/link
*/
/**
* The link plugin.
*
* This is a "glue" plugin that loads the {@link module:link/linkediting~LinkEditing link editing feature}
* and {@link module:link/linkui~LinkUI link UI feature}.
*/
var Link = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
LinkEditing,
LinkUI,
AutoLink
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "Link";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @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/linkimageediting
*/
/**
* The link image engine feature.
*
* It accepts the `linkHref="url"` attribute in the model for the {@link module:image/image~Image `<imageBlock>`} element
* which allows linking images.
*/
var LinkImageEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
ImageEditing,
ImageUtils,
LinkEditing
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "LinkImageEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const schema = editor.model.schema;
if (editor.plugins.has("ImageBlockEditing")) schema.extend("imageBlock", { allowAttributes: ["linkHref"] });
editor.conversion.for("upcast").add(upcastLink(editor));
editor.conversion.for("downcast").add(downcastImageLink(editor));
this._enableAutomaticDecorators();
this._enableManualDecorators();
}
/**
* Processes {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition automatic decorators} definitions and
* attaches proper converters that will work when linking an image.`
*/
_enableAutomaticDecorators() {
const editor = this.editor;
const automaticDecorators = editor.commands.get("link").automaticDecorators;
if (automaticDecorators.length) editor.conversion.for("downcast").add(automaticDecorators.getDispatcherForLinkedImage());
}
/**
* Processes transformed {@link module:link/utils/manualdecorator~LinkManualDecorator} instances and attaches proper converters
* that will work when linking an image.
*/
_enableManualDecorators() {
const editor = this.editor;
const command = editor.commands.get("link");
for (const decorator of command.manualDecorators) {
if (editor.plugins.has("ImageBlockEditing")) editor.model.schema.extend("imageBlock", { allowAttributes: decorator.id });
if (editor.plugins.has("ImageInlineEditing")) editor.model.schema.extend("imageInline", { allowAttributes: decorator.id });
editor.conversion.for("downcast").add(downcastImageLinkManualDecorator(decorator));
editor.conversion.for("upcast").add(upcastImageLinkManualDecorator(editor, decorator));
}
}
};
/**
* Returns a converter for linked block images that consumes the "href" attribute
* if a link contains an image.
*
* @param editor The editor instance.
*/
function upcastLink(editor) {
const isImageInlinePluginLoaded = editor.plugins.has("ImageInlineEditing");
const imageUtils = editor.plugins.get("ImageUtils");
return (dispatcher) => {
dispatcher.on("element:a", (evt, data, conversionApi) => {
const viewLink = data.viewItem;
const imageInLink = imageUtils.findViewImgElement(viewLink);
if (!imageInLink) return;
const blockImageView = imageInLink.findAncestor((element) => imageUtils.isBlockImageView(element));
if (isImageInlinePluginLoaded && !blockImageView) return;
const consumableAttributes = { attributes: ["href"] };
if (!conversionApi.consumable.test(viewLink, consumableAttributes)) return;
const linkHref = viewLink.getAttribute("href");
const modelElement = data.modelCursor.parent;
if (modelElement.is("element", "imageBlock")) {
conversionApi.writer.setAttribute("linkHref", linkHref, modelElement);
conversionApi.consumable.consume(viewLink, consumableAttributes);
}
}, { priority: "high" });
};
}
/**
* Creates a converter that adds `<a>` to linked block image view elements.
*/
function downcastImageLink(editor) {
const imageUtils = editor.plugins.get("ImageUtils");
return (dispatcher) => {
dispatcher.on("attribute:linkHref:imageBlock", (evt, data, conversionApi) => {
if (!conversionApi.consumable.consume(data.item, evt.name)) return;
const viewFigure = conversionApi.mapper.toViewElement(data.item);
const writer = conversionApi.writer;
const linkInImage = Array.from(viewFigure.getChildren()).find((child) => child.is("element", "a"));
const viewImage = imageUtils.findViewImgElement(viewFigure);
const viewImgOrPicture = viewImage.parent.is("element", "picture") ? viewImage.parent : viewImage;
if (linkInImage) if (data.attributeNewValue) writer.setAttribute("href", data.attributeNewValue, linkInImage);
else {
writer.move(writer.createRangeOn(viewImgOrPicture), writer.createPositionAt(viewFigure, 0));
writer.remove(linkInImage);
}
else {
const linkElement = writer.createContainerElement("a", { href: data.attributeNewValue });
writer.insert(writer.createPositionAt(viewFigure, 0), linkElement);
writer.move(writer.createRangeOn(viewImgOrPicture), writer.createPositionAt(linkElement, 0));
}
}, { priority: "high" });
};
}
/**
* Returns a converter that decorates the `<a>` element when the image is the link label.
*/
function downcastImageLinkManualDecorator(decorator) {
return (dispatcher) => {
const createConverter = (isApplyingConverter) => {
return (evt, data, conversionApi) => {
const viewFigure = conversionApi.mapper.toViewElement(data.item);
const linkInImage = Array.from(viewFigure.getChildren()).find((child) => child.is("element", "a"));
if (!linkInImage) return;
if (!isApplyingConverter && data.attributeOldValue) {
for (const [key, val] of toMap(decorator.attributes)) conversionApi.writer.removeAttribute(key, val, linkInImage);
if (decorator.classes) conversionApi.writer.removeClass(decorator.classes, linkInImage);
for (const key in decorator.styles) conversionApi.writer.removeStyle(key, linkInImage);
}
if (isApplyingConverter && data.attributeNewValue) {
for (const [key, val] of toMap(decorator.attributes)) conversionApi.writer.setAttribute(key, val, false, linkInImage);
if (decorator.classes) conversionApi.writer.addClass(decorator.classes, linkInImage);
for (const key in decorator.styles) conversionApi.writer.setStyle(key, decorator.styles[key], linkInImage);
}
};
};
dispatcher.on(`attribute:${decorator.id}:imageBlock`, createConverter(false), { priority: priorities.high - 1 });
dispatcher.on(`attribute:${decorator.id}:imageBlock`, createConverter(true), { priority: priorities.high - 2 });
};
}
/**
* Returns a converter that checks whether manual decorators should be applied to the link.
*/
function upcastImageLinkManualDecorator(editor, decorator) {
const isImageInlinePluginLoaded = editor.plugins.has("ImageInlineEditing");
const imageUtils = editor.plugins.get("ImageUtils");
return (dispatcher) => {
dispatcher.on("element:a", (evt, data, conversionApi) => {
const viewLink = data.viewItem;
const imageInLink = imageUtils.findViewImgElement(viewLink);
if (!imageInLink) return;
const blockImageView = imageInLink.findAncestor((element) => imageUtils.isBlockImageView(element));
if (isImageInlinePluginLoaded && !blockImageView) return;
const result = new Matcher(decorator._createPattern()).match(viewLink);
if (!result) return;
if (!conversionApi.consumable.test(viewLink, result.match)) return;
const modelElement = data.modelCursor.parent;
if (modelElement?.is("element", "imageBlock")) {
conversionApi.writer.setAttribute(decorator.id, true, modelElement);
conversionApi.consumable.consume(viewLink, result.match);
}
}, { priority: "high" });
};
}
/**
* @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/linkimageui
*/
/**
* The link image UI plugin.
*
* This plugin provides the `'linkImage'` button that can be displayed in the {@link module:image/imagetoolbar~ImageToolbar}.
* It can be used to wrap images in links.
*/
var LinkImageUI = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [
LinkEditing,
LinkUI,
ImageBlockEditing
];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "LinkImageUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const viewDocument = editor.editing.view.document;
this.listenTo(viewDocument, "click", (evt, data) => {
if (this._isSelectedLinkedImage(editor.model.document.selection)) {
data.preventDefault();
evt.stop();
}
}, { priority: "high" });
this._createToolbarLinkImageButton();
}
/**
* Creates a `LinkImageUI` button view.
*
* Clicking this button shows a {@link module:link/linkui~LinkUI#_balloon} attached to the selection.
* When an image is already linked, the view shows {@link module:link/linkui~LinkUI#toolbarView} or
* {@link module:link/linkui~LinkUI#formView} if it is not.
*/
_createToolbarLinkImageButton() {
const editor = this.editor;
const t = editor.t;
editor.ui.componentFactory.add("linkImage", (locale) => {
const button = new ButtonView(locale);
const plugin = editor.plugins.get("LinkUI");
const linkCommand = editor.commands.get("link");
button.set({
isEnabled: true,
label: t("Link image"),
icon: IconLink,
keystroke: LINK_KEYSTROKE,
tooltip: true,
isToggleable: true
});
button.bind("isEnabled").to(linkCommand, "isEnabled");
button.bind("isOn").to(linkCommand, "value", (value) => !!value);
this.listenTo(button, "execute", () => {
if (this._isSelectedLinkedImage(editor.model.document.selection)) plugin._addToolbarView();
else plugin._showUI(true);
});
return button;
});
}
/**
* Returns true if a linked image (either block or inline) is the only selected element
* in the model document.
*/
_isSelectedLinkedImage(selection) {
const selectedModelElement = selection.getSelectedElement();
return this.editor.plugins.get("ImageUtils").isImage(selectedModelElement) && selectedModelElement.hasAttribute("linkHref");
}
};
/**
* @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/linkimage
*/
/**
* The `LinkImage` plugin.
*
* This is a "glue" plugin that loads the {@link module:link/linkimageediting~LinkImageEditing link image editing feature}
* and {@link module:link/linkimageui~LinkImageUI link image UI feature}.
*/
var LinkImage = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [LinkImageEditing, LinkImageUI];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "LinkImage";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @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
*/
export { AutoLink, AutomaticLinkDecorators, Link, LinkCommand, LinkEditing, LinkFormView, LinkImage, LinkImageEditing, LinkImageUI, LinkManualDecorator, LinkPropertiesView, LinkProviderItemsView, LinkUI, UnlinkCommand, LINK_KEYSTROKE as _LINK_KEYSTROKE, LinkButtonView as _LinkButtonView, LinkPreviewButtonView as _LinkPreviewButtonView, createLinkElement as _createLinkElement, ensureSafeUrl as _ensureSafeLinkUrl, extractTextFromLinkRange as _extractTextFromLinkRange, getLocalizedDecorators as _getLocalizedLinkDecorators, linkHasProtocol as _hasLinkProtocol, isEmail as _isEmailLink, normalizeDecorators as _normalizeLinkDecorators, openLink as _openLink, addLinkProtocolIfApplicable, isLinkElement, isLinkableElement };
//# sourceMappingURL=index.js.map