@ckeditor/ckeditor5-utils
Version:
Miscellaneous utilities used by CKEditor 5.
1,337 lines (1,323 loc) • 210 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 { cloneDeepWith, isElement, isFunction, isObject, isPlainObject, isString, merge } from "es-toolkit/compat";
/**
* @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
*/
/**
* A helper (module) giving an access to the global DOM objects such as `window` and
* `document`. Accessing these objects using this helper allows easy and bulletproof
* testing, i.e. stubbing native properties:
*
* ```ts
* import { global } from 'ckeditor5';
*
* // This stub will work for any code using global module.
* testUtils.sinon.stub( global, 'window', {
* innerWidth: 10000
* } );
*
* console.log( global.window.innerWidth );
* ```
*/
let globalVar;
try {
globalVar = {
window,
document
};
} catch {
/* v8 ignore start -- @preserve */
globalVar = {
window: {},
document: {}
};
}
/**
* @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 utils/env
*/
/**
* Safely returns `userAgent` from browser's navigator API in a lower case.
* If navigator API is not available it will return an empty string.
*
* @internal
*/
function getUserAgent() {
try {
return navigator.userAgent.toLowerCase();
} catch {
return "";
}
}
const userAgent = /* #__PURE__ */ getUserAgent();
/**
* A namespace containing environment and browser information.
*/
const env = {
isMac: /* #__PURE__ */ isMac(userAgent),
isWindows: /* #__PURE__ */ isWindows(userAgent),
isGecko: /* #__PURE__ */ isGecko(userAgent),
isSafari: /* #__PURE__ */ isSafari(userAgent),
isiOS: /* #__PURE__ */ isiOS(userAgent),
isAndroid: /* #__PURE__ */ isAndroid(userAgent),
isBlink: /* #__PURE__ */ isBlink(userAgent),
get isMediaForcedColors() {
return isMediaForcedColors();
},
get isMotionReduced() {
return isMotionReduced();
},
features: { isRegExpUnicodePropertySupported: /* #__PURE__ */ isRegExpUnicodePropertySupported() }
};
/**
* Checks if User Agent represented by the string is running on Macintosh.
*
* @internal
* @param userAgent **Lowercase** `navigator.userAgent` string.
* @returns Whether User Agent is running on Macintosh or not.
*/
function isMac(userAgent) {
return userAgent.indexOf("macintosh") > -1;
}
/**
* Checks if User Agent represented by the string is running on Windows.
*
* @internal
* @param userAgent **Lowercase** `navigator.userAgent` string.
* @returns Whether User Agent is running on Windows or not.
*/
function isWindows(userAgent) {
return userAgent.indexOf("windows") > -1;
}
/**
* Checks if User Agent represented by the string is Firefox (Gecko).
*
* @internal
* @param userAgent **Lowercase** `navigator.userAgent` string.
* @returns Whether User Agent is Firefox or not.
*/
function isGecko(userAgent) {
return !!userAgent.match(/gecko\/\d+/);
}
/**
* Checks if User Agent represented by the string is Safari.
*
* @internal
* @param userAgent **Lowercase** `navigator.userAgent` string.
* @returns Whether User Agent is Safari or not.
*/
function isSafari(userAgent) {
return userAgent.indexOf(" applewebkit/") > -1 && userAgent.indexOf("chrome") === -1;
}
/**
* Checks if User Agent represented by the string is running in iOS.
*
* @internal
* @param userAgent **Lowercase** `navigator.userAgent` string.
* @returns Whether User Agent is running in iOS or not.
*/
function isiOS(userAgent) {
return !!userAgent.match(/iphone|ipad/i) || isMac(userAgent) && navigator.maxTouchPoints > 0;
}
/**
* Checks if User Agent represented by the string is Android mobile device.
*
* @internal
* @param userAgent **Lowercase** `navigator.userAgent` string.
* @returns Whether User Agent is Safari or not.
*/
function isAndroid(userAgent) {
return userAgent.indexOf("android") > -1;
}
/**
* Checks if User Agent represented by the string is Blink engine.
*
* @internal
* @param userAgent **Lowercase** `navigator.userAgent` string.
* @returns Whether User Agent is Blink engine or not.
*/
function isBlink(userAgent) {
return userAgent.indexOf("chrome/") > -1 && userAgent.indexOf("edge/") < 0;
}
/**
* Checks if the current environment supports ES2018 Unicode properties like `\p{P}` or `\p{L}`.
* More information about unicode properties might be found
* [in Unicode Standard Annex #44](https://www.unicode.org/reports/tr44/#GC_Values_Table).
*
* @internal
*/
function isRegExpUnicodePropertySupported() {
let isSupported = false;
try {
isSupported = "ć".search(/* @__PURE__ */ new RegExp("[\\p{L}]", "u")) === 0;
} catch {}
return isSupported;
}
/**
* Checks if the user agent has enabled a forced colors mode (e.g. Windows High Contrast mode).
*
* Returns `false` in environments where `window` global object is not available.
*
* @internal
*/
function isMediaForcedColors() {
return globalVar.window.matchMedia ? globalVar.window.matchMedia("(forced-colors: active)").matches : false;
}
/**
* Checks if the user enabled "prefers reduced motion" setting in browser.
*
* Returns `false` in environments where `window` global object is not available.
*
* @internal
*/
function isMotionReduced() {
return globalVar.window.matchMedia ? globalVar.window.matchMedia("(prefers-reduced-motion)").matches : false;
}
/**
* @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
*/
/**
* Finds positions of the first and last change in the given string/array and generates a set of changes:
*
* ```ts
* fastDiff( '12a', '12xyza' );
* // [ { index: 2, type: 'insert', values: [ 'x', 'y', 'z' ] } ]
*
* fastDiff( '12a', '12aa' );
* // [ { index: 3, type: 'insert', values: [ 'a' ] } ]
*
* fastDiff( '12xyza', '12a' );
* // [ { index: 2, type: 'delete', howMany: 3 } ]
*
* fastDiff( [ '1', '2', 'a', 'a' ], [ '1', '2', 'a' ] );
* // [ { index: 3, type: 'delete', howMany: 1 } ]
*
* fastDiff( [ '1', '2', 'a', 'b', 'c', '3' ], [ '2', 'a', 'b' ] );
* // [ { index: 0, type: 'insert', values: [ '2', 'a', 'b' ] }, { index: 3, type: 'delete', howMany: 6 } ]
* ```
*
* Passed arrays can contain any type of data, however to compare them correctly custom comparator function
* should be passed as a third parameter:
*
* ```ts
* fastDiff( [ { value: 1 }, { value: 2 } ], [ { value: 1 }, { value: 3 } ], ( a, b ) => {
* return a.value === b.value;
* } );
* // [ { index: 1, type: 'insert', values: [ { value: 3 } ] }, { index: 2, type: 'delete', howMany: 1 } ]
* ```
*
* The resulted set of changes can be applied to the input in order to transform it into the output, for example:
*
* ```ts
* let input = '12abc3';
* const output = '2ab';
* const changes = fastDiff( input, output );
*
* changes.forEach( change => {
* if ( change.type == 'insert' ) {
* input = input.substring( 0, change.index ) + change.values.join( '' ) + input.substring( change.index );
* } else if ( change.type == 'delete' ) {
* input = input.substring( 0, change.index ) + input.substring( change.index + change.howMany );
* }
* } );
*
* // input equals output now
* ```
*
* or in case of arrays:
*
* ```ts
* let input = [ '1', '2', 'a', 'b', 'c', '3' ];
* const output = [ '2', 'a', 'b' ];
* const changes = fastDiff( input, output );
*
* changes.forEach( change => {
* if ( change.type == 'insert' ) {
* input = input.slice( 0, change.index ).concat( change.values, input.slice( change.index ) );
* } else if ( change.type == 'delete' ) {
* input = input.slice( 0, change.index ).concat( input.slice( change.index + change.howMany ) );
* }
* } );
*
* // input equals output now
* ```
*
* By passing `true` as the fourth parameter (`atomicChanges`) the output of this function will become compatible with
* the {@link module:utils/diff~diff `diff()`} function:
*
* ```ts
* fastDiff( '12a', '12xyza', undefined, true );
* // [ 'equal', 'equal', 'insert', 'insert', 'insert', 'equal' ]
* ```
*
* The default output format of this function is compatible with the output format of
* {@link module:utils/difftochanges~diffToChanges `diffToChanges()`}. The `diffToChanges()` input format is, in turn,
* compatible with the output of {@link module:utils/diff~diff `diff()`}:
*
* ```ts
* const a = '1234';
* const b = '12xyz34';
*
* // Both calls will return the same results (grouped changes format).
* fastDiff( a, b );
* diffToChanges( diff( a, b ) );
*
* // Again, both calls will return the same results (atomic changes format).
* fastDiff( a, b, undefined, true );
* diff( a, b );
* ```
*
* @typeParam T The type of array elements.
* @typeParam AtomicChanges The type of `atomicChanges` parameter (selects the result type).
* @param a Input array or string.
* @param b Input array or string.
* @param cmp Optional function used to compare array values, by default `===` (strict equal operator) is used.
* @param atomicChanges Whether an array of `inset|delete|equal` operations should
* be returned instead of changes set. This makes this function compatible with {@link module:utils/diff~diff `diff()`}.
* Defaults to `false`.
* @returns Array of changes. The elements are either {@link module:utils/diff~DiffResult} or {@link module:utils/difftochanges~Change},
* depending on `atomicChanges` parameter.
*/
function fastDiff(a, b, cmp, atomicChanges) {
cmp = cmp || function(a, b) {
return a === b;
};
const arrayA = Array.isArray(a) ? a : Array.prototype.slice.call(a);
const arrayB = Array.isArray(b) ? b : Array.prototype.slice.call(b);
const changeIndexes = findChangeBoundaryIndexes(arrayA, arrayB, cmp);
return atomicChanges ? changeIndexesToAtomicChanges(changeIndexes, arrayB.length) : changeIndexesToChanges(arrayB, changeIndexes);
}
/**
* Finds position of the first and last change in the given arrays. For example:
*
* ```ts
* const indexes = findChangeBoundaryIndexes( [ '1', '2', '3', '4' ], [ '1', '3', '4', '2', '4' ] );
* console.log( indexes ); // { firstIndex: 1, lastIndexOld: 3, lastIndexNew: 4 }
* ```
*
* The above indexes means that in the first array the modified part is `1[23]4` and in the second array it is `1[342]4`.
* Based on such indexes, array with `insert`/`delete` operations which allows transforming first value into the second one
* can be generated.
*/
function findChangeBoundaryIndexes(arr1, arr2, cmp) {
const firstIndex = findFirstDifferenceIndex(arr1, arr2, cmp);
if (firstIndex === -1) return {
firstIndex: -1,
lastIndexOld: -1,
lastIndexNew: -1
};
const lastIndex = findFirstDifferenceIndex(cutAndReverse(arr1, firstIndex), cutAndReverse(arr2, firstIndex), cmp);
return {
firstIndex,
lastIndexOld: arr1.length - lastIndex,
lastIndexNew: arr2.length - lastIndex
};
}
/**
* Returns a first index on which given arrays differ. If both arrays are the same, -1 is returned.
*/
function findFirstDifferenceIndex(arr1, arr2, cmp) {
for (let i = 0; i < Math.max(arr1.length, arr2.length); i++) if (arr1[i] === void 0 || arr2[i] === void 0 || !cmp(arr1[i], arr2[i])) return i;
return -1;
}
/**
* Returns a copy of the given array with `howMany` elements removed starting from the beginning and in reversed order.
*
* @param arr Array to be processed.
* @param howMany How many elements from array beginning to remove.
* @returns Shortened and reversed array.
*/
function cutAndReverse(arr, howMany) {
return arr.slice(howMany).reverse();
}
/**
* Generates changes array based on change indexes from `findChangeBoundaryIndexes` function. This function will
* generate array with 0 (no changes), 1 (deletion or insertion) or 2 records (insertion and deletion).
*
* @param newArray New array for which change indexes were calculated.
* @param changeIndexes Change indexes object from `findChangeBoundaryIndexes` function.
* @returns Array of changes compatible with {@link module:utils/difftochanges~diffToChanges} format.
*/
function changeIndexesToChanges(newArray, changeIndexes) {
const result = [];
const { firstIndex, lastIndexOld, lastIndexNew } = changeIndexes;
if (lastIndexNew - firstIndex > 0) result.push({
index: firstIndex,
type: "insert",
values: newArray.slice(firstIndex, lastIndexNew)
});
if (lastIndexOld - firstIndex > 0) result.push({
index: firstIndex + (lastIndexNew - firstIndex),
type: "delete",
howMany: lastIndexOld - firstIndex
});
return result;
}
/**
* Generates array with set `equal|insert|delete` operations based on change indexes from `findChangeBoundaryIndexes` function.
*
* @param changeIndexes Change indexes object from `findChangeBoundaryIndexes` function.
* @param newLength Length of the new array on which `findChangeBoundaryIndexes` calculated change indexes.
* @returns Array of changes compatible with {@link module:utils/diff~diff} format.
*/
function changeIndexesToAtomicChanges(changeIndexes, newLength) {
const { firstIndex, lastIndexOld, lastIndexNew } = changeIndexes;
if (firstIndex === -1) return Array(newLength).fill("equal");
let result = [];
if (firstIndex > 0) result = result.concat(Array(firstIndex).fill("equal"));
if (lastIndexNew - firstIndex > 0) result = result.concat(Array(lastIndexNew - firstIndex).fill("insert"));
if (lastIndexOld - firstIndex > 0) result = result.concat(Array(lastIndexOld - firstIndex).fill("delete"));
if (lastIndexNew < newLength) result = result.concat(Array(newLength - lastIndexNew).fill("equal"));
return result;
}
/**
* @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 utils/diff
*/
/**
* Calculates the difference between two arrays or strings producing an array containing a list of changes
* necessary to transform input into output.
*
* ```ts
* diff( 'aba', 'acca' ); // [ 'equal', 'insert', 'insert', 'delete', 'equal' ]
* ```
*
* This function is based on the "O(NP) Sequence Comparison Algorithm" by Sun Wu, Udi Manber, Gene Myers, Webb Miller.
* Unfortunately, while it gives the most precise results, its to complex for longer strings/arrow (above 200 items).
* Therefore, `diff()` automatically switches to {@link module:utils/fastdiff~fastDiff `fastDiff()`} when detecting
* such a scenario. The return formats of both functions are identical.
*
* @param a Input array or string.
* @param b Output array or string.
* @param cmp Optional function used to compare array values, by default === is used.
* @returns Array of changes.
*/
const diff = function(a, b, cmp) {
cmp = cmp || function(a, b) {
return a === b;
};
const aLength = a.length;
const bLength = b.length;
if (aLength > 200 || bLength > 200 || aLength + bLength > 300) return diff.fastDiff(a, b, cmp, true);
let _insert, _delete;
if (bLength < aLength) {
const tmp = a;
a = b;
b = tmp;
_insert = "delete";
_delete = "insert";
} else {
_insert = "insert";
_delete = "delete";
}
const m = a.length;
const n = b.length;
const delta = n - m;
const es = {};
const fp = {};
function snake(k) {
const y1 = (fp[k - 1] !== void 0 ? fp[k - 1] : -1) + 1;
const y2 = fp[k + 1] !== void 0 ? fp[k + 1] : -1;
const dir = y1 > y2 ? -1 : 1;
if (es[k + dir]) es[k] = es[k + dir].slice(0);
if (!es[k]) es[k] = [];
es[k].push(y1 > y2 ? _insert : _delete);
let y = Math.max(y1, y2);
let x = y - k;
while (x < m && y < n && cmp(a[x], b[y])) {
x++;
y++;
es[k].push("equal");
}
return y;
}
let p = 0;
let k;
do {
for (k = -p; k < delta; k++) fp[k] = snake(k);
for (k = delta + p; k > delta; k--) fp[k] = snake(k);
fp[delta] = snake(delta);
p++;
} while (fp[delta] !== n);
return es[delta].slice(1);
};
diff.fastDiff = fastDiff;
/**
* @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
*/
/**
* Creates a set of changes which need to be applied to the input in order to transform
* it into the output. This function can be used with strings or arrays.
*
* ```ts
* const input = Array.from( 'abc' );
* const output = Array.from( 'xaby' );
* const changes = diffToChanges( diff( input, output ), output );
*
* changes.forEach( change => {
* if ( change.type == 'insert' ) {
* input.splice( change.index, 0, ...change.values );
* } else if ( change.type == 'delete' ) {
* input.splice( change.index, change.howMany );
* }
* } );
*
* input.join( '' ) == output.join( '' ); // -> true
* ```
*
* @typeParam T The type of output array element.
* @param diff Result of {@link module:utils/diff~diff}.
* @param output The string or array which was passed as diff's output.
* @returns Set of changes (insert or delete) which need to be applied to the input
* in order to transform it into the output.
*/
function diffToChanges(diff, output) {
const changes = [];
let index = 0;
let lastOperation = null;
diff.forEach((change) => {
if (change == "equal") {
pushLast();
index++;
} else if (change == "insert") {
if (lastOperation && lastOperation.type == "insert") lastOperation.values.push(output[index]);
else {
pushLast();
lastOperation = {
type: "insert",
index,
values: [output[index]]
};
}
index++;
} else if (lastOperation && lastOperation.type == "delete") lastOperation.howMany++;
else {
pushLast();
lastOperation = {
type: "delete",
index,
howMany: 1
};
}
});
pushLast();
return changes;
function pushLast() {
if (lastOperation) {
changes.push(lastOperation);
lastOperation = null;
}
}
}
/**
* @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 utils/areconnectedthroughproperties
*/
/**
* Traverses both structures to find out whether there is a reference that is shared between both structures.
*/
function areConnectedThroughProperties(obj1, obj2) {
if (obj1 === obj2 && isObject$1(obj1)) return true;
const subNodes1 = getSubNodes(obj1);
const subNodes2 = getSubNodes(obj2);
for (const node of subNodes1) if (subNodes2.has(node)) return true;
return false;
}
/**
* Traverses JS structure and stores all sub-nodes, including the head node.
* It walks into each iterable structures with the `try catch` block to omit errors that might be thrown during
* tree walking. All primitives, functions and built-ins are skipped.
*/
function getSubNodes(head) {
const nodes = [head];
const subNodes = /* @__PURE__ */ new Set();
while (nodes.length > 0) {
const node = nodes.shift();
if (subNodes.has(node) || shouldNodeBeSkipped(node)) continue;
subNodes.add(node);
if (node[Symbol.iterator]) try {
nodes.push(...node);
} catch {}
else nodes.push(...Object.values(node));
}
return subNodes;
}
function shouldNodeBeSkipped(node) {
const type = Object.prototype.toString.call(node);
return type === "[object Number]" || type === "[object Boolean]" || type === "[object String]" || type === "[object Symbol]" || type === "[object Function]" || type === "[object Date]" || type === "[object RegExp]" || node === void 0 || node === null || node instanceof EventTarget || node instanceof Event;
}
function isObject$1(structure) {
return typeof structure === "object" && structure !== null;
}
/**
* @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 utils/spy
*/
/**
* Creates a spy function (ala Sinon.js) that can be used to inspect call to it.
*
* The following are the present features:
*
* * spy.called: property set to `true` if the function has been called at least once.
*
* @returns The spy function.
*/
function spy() {
return function spy() {
spy.called = 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 utils/eventinfo
*/
/**
* The event object passed to event callbacks. It is used to provide information about the event as well as a tool to
* manipulate it.
*/
var EventInfo = class {
/**
* The object that fired the event.
*/
source;
/**
* The event name.
*/
name;
/**
* Path this event has followed. See {@link module:utils/emittermixin~Emitter#delegate}.
*/
path;
/**
* Stops the event emitter to call further callbacks for this event interaction.
*/
stop;
/**
* Removes the current callback from future interactions of this event.
*/
off;
/**
* The value which will be returned by {@link module:utils/emittermixin~Emitter#fire}.
*
* It's `undefined` by default and can be changed by an event listener:
*
* ```ts
* dataController.fire( 'getSelectedContent', ( evt ) => {
* // This listener will make `dataController.fire( 'getSelectedContent' )`
* // always return an empty DocumentFragment.
* evt.return = new DocumentFragment();
*
* // Make sure no other listeners are executed.
* evt.stop();
* } );
* ```
*/
return;
/**
* @param source The emitter.
* @param name The event name.
*/
constructor(source, name) {
this.source = source;
this.name = name;
this.path = [];
this.stop = spy();
this.off = spy();
}
};
/**
* @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 utils/uid
*/
/**
* A hash table of hex numbers to avoid using toString() in uid() which is costly.
* [ '00', '01', '02', ..., 'fe', 'ff' ]
*/
const HEX_NUMBERS = new Array(256).fill("").map((_, index) => ("0" + index.toString(16)).slice(-2));
/**
* Returns a unique id. The id starts with an "e" character and a randomly generated string of
* 32 alphanumeric characters.
*
* **Note**: The characters the unique id is built from correspond to the hex number notation
* (from "0" to "9", from "a" to "f"). In other words, each id corresponds to an "e" followed
* by 16 8-bit numbers next to each other.
*
* @returns An unique id string.
*/
function uid() {
const [r1, r2, r3, r4] = crypto.getRandomValues(new Uint32Array(4));
return "e" + HEX_NUMBERS[r1 >> 0 & 255] + HEX_NUMBERS[r1 >> 8 & 255] + HEX_NUMBERS[r1 >> 16 & 255] + HEX_NUMBERS[r1 >> 24 & 255] + HEX_NUMBERS[r2 >> 0 & 255] + HEX_NUMBERS[r2 >> 8 & 255] + HEX_NUMBERS[r2 >> 16 & 255] + HEX_NUMBERS[r2 >> 24 & 255] + HEX_NUMBERS[r3 >> 0 & 255] + HEX_NUMBERS[r3 >> 8 & 255] + HEX_NUMBERS[r3 >> 16 & 255] + HEX_NUMBERS[r3 >> 24 & 255] + HEX_NUMBERS[r4 >> 0 & 255] + HEX_NUMBERS[r4 >> 8 & 255] + HEX_NUMBERS[r4 >> 16 & 255] + HEX_NUMBERS[r4 >> 24 & 255];
}
/**
* @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
*/
/**
* Provides group of constants to use instead of hardcoding numeric priority values.
*/
const priorities = {
get(priority = "normal") {
if (typeof priority != "number") return this[priority] || this.normal;
else return priority;
},
highest: 1e5,
high: 1e3,
normal: 0,
low: -1e3,
lowest: -1e5
};
/**
* @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 utils/inserttopriorityarray
*/
/**
* Inserts any object with priority at correct index by priority so registered objects are always sorted from highest to lowest priority.
*
* @param objects Array of objects with priority to insert object to.
* @param objectToInsert Object with `priority` property.
*/
function insertToPriorityArray(objects, objectToInsert) {
const priority = priorities.get(objectToInsert.priority);
let left = 0;
let right = objects.length;
while (left < right) {
const mid = left + right >> 1;
if (priorities.get(objects[mid].priority) < priority) right = mid;
else left = mid + 1;
}
objects.splice(left, 0, objectToInsert);
}
/**
* @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 utils/ckeditorerror
*/
/**
* URL to the documentation with error codes.
*/
const DOCUMENTATION_URL = "https://ckeditor.com/docs/ckeditor5/latest/support/error-codes.html";
/**
* The CKEditor error class.
*
* You should throw `CKEditorError` when:
*
* * An unexpected situation occurred and the editor (most probably) will not work properly. Such exception will be handled
* by the {@link module:watchdog/watchdog~Watchdog watchdog} (if it is integrated),
* * If the editor is incorrectly integrated or the editor API is used in the wrong way. This way you will give
* feedback to the developer as soon as possible. Keep in mind that for common integration issues which should not
* stop editor initialization (like missing upload adapter, wrong name of a toolbar component) we use
* {@link module:utils/ckeditorerror~logWarning `logWarning()`} and
* {@link module:utils/ckeditorerror~logError `logError()`}
* to improve developers experience and let them see the a working editor as soon as possible.
*
* ```ts
* /**
* * Error thrown when a plugin cannot be loaded due to JavaScript errors, lack of plugins with a given name, etc.
* *
* * @error plugin-load
* * @param pluginName The name of the plugin that could not be loaded.
* * @param moduleName The name of the module which tried to load this plugin.
* *\/
* throw new CKEditorError( 'plugin-load', {
* pluginName: 'foo',
* moduleName: 'bar'
* } );
* ```
*/
var CKEditorError = class CKEditorError extends Error {
/**
* A context of the error by which the Watchdog is able to determine which editor crashed.
*/
context;
/**
* The additional error data passed to the constructor. Undefined if none was passed.
*/
data;
/**
* Creates an instance of the CKEditorError class.
*
* @param errorName The error id in an `error-name` format. A link to this error documentation page will be added
* to the thrown error's `message`.
* @param context A context of the error by which the {@link module:watchdog/watchdog~Watchdog watchdog}
* is able to determine which editor crashed. It should be an editor instance or a property connected to it. It can be also
* a `null` value if the editor should not be restarted in case of the error (e.g. during the editor initialization).
* The error context should be checked using the `areConnectedThroughProperties( editor, context )` utility
* to check if the object works as the context.
* @param data Additional data describing the error. A stringified version of this object
* will be appended to the error message, so the data are quickly visible in the console. The original
* data object will also be later available under the {@link #data} property.
* @param originalError An optional original error that is being wrapped in the `CKEditorError` instance.
*/
constructor(errorName, context, data, originalError) {
super(getErrorMessage(errorName, data, originalError));
this.name = "CKEditorError";
this.context = context;
this.data = data;
if (originalError) this.stack = originalError.stack;
}
/**
* Checks if the error is of the `CKEditorError` type.
*/
is(type) {
return type === "CKEditorError";
}
/**
* A utility that ensures that the thrown error is a {@link module:utils/ckeditorerror~CKEditorError} one.
* It is useful when combined with the {@link module:watchdog/watchdog~Watchdog} feature, which can restart the editor in case
* of a {@link module:utils/ckeditorerror~CKEditorError} error.
*
* @param error The error to rethrow.
* @param context An object connected through properties with the editor instance. This context will be used
* by the watchdog to verify which editor should be restarted.
*/
static rethrowUnexpectedError(error, context) {
if (error.is && error.is("CKEditorError")) throw error;
/**
* An unexpected error occurred inside the CKEditor 5 codebase. This error will look like the original one
* to make the debugging easier.
*
* This error is only useful when the editor is initialized using the {@link module:watchdog/watchdog~Watchdog} feature.
* In case of such error (or any {@link module:utils/ckeditorerror~CKEditorError} error) the watchdog should restart the editor.
*
* @error unexpected-error
*/
throw new CKEditorError("unexpected-error", context, void 0, error);
}
};
/**
* Logs a warning to the console with a properly formatted message and adds a link to the documentation.
* Use whenever you want to log a warning to the console.
*
* ```ts
* /**
* * There was a problem processing the configuration of the toolbar. The item with the given
* * name does not exist, so it was omitted when rendering the toolbar.
* *
* * @error toolbarview-item-unavailable
* * @param {String} name The name of the component.
* *\/
* logWarning( 'toolbarview-item-unavailable', { name } );
* ```
*
* See also {@link module:utils/ckeditorerror~CKEditorError} for an explanation when to throw an error and when to log
* a warning or an error to the console.
*
* @param errorName The error name to be logged.
* @param data Additional data to be logged.
*/
function logWarning(errorName, data) {
console.warn(...formatConsoleArguments(errorName, data));
}
/**
* Logs an error to the console with a properly formatted message and adds a link to the documentation.
* Use whenever you want to log an error to the console.
*
* ```ts
* /**
* * There was a problem processing the configuration of the toolbar. The item with the given
* * name does not exist, so it was omitted when rendering the toolbar.
* *
* * @error toolbarview-item-unavailable
* * @param {String} name The name of the component.
* *\/
* logError( 'toolbarview-item-unavailable', { name } );
* ```
*
* **Note**: In most cases logging a warning using {@link module:utils/ckeditorerror~logWarning} is enough.
*
* See also {@link module:utils/ckeditorerror~CKEditorError} for an explanation when to use each method.
*
* @param errorName The error name to be logged.
* @param data Additional data to be logged.
*/
function logError(errorName, data) {
console.error(...formatConsoleArguments(errorName, data));
}
/**
* Returns formatted link to documentation message.
*/
function getLinkToDocumentationMessage(errorName) {
return `\nRead more: ${DOCUMENTATION_URL}#error-${errorName}`;
}
/**
* Returns formatted error message.
*/
function getErrorMessage(errorName, data, originalError) {
const processedObjects = /* @__PURE__ */ new WeakSet();
const circularReferencesReplacer = (key, value) => {
if (typeof value === "object" && value !== null) {
if (processedObjects.has(value)) return `[object ${value.constructor.name}]`;
processedObjects.add(value);
}
return value;
};
const stringifiedData = data ? ` ${JSON.stringify(data, circularReferencesReplacer)}` : "";
const documentationLink = getLinkToDocumentationMessage(errorName);
const originalErrorMessage = originalError ? `\nOriginal error: ${originalError.name}: ${originalError.message}` : "";
return errorName + stringifiedData + documentationLink + originalErrorMessage;
}
/**
* Returns formatted console error arguments.
*/
function formatConsoleArguments(errorName, data) {
const documentationMessage = getLinkToDocumentationMessage(errorName);
return data ? [
errorName,
data,
documentationMessage
] : [errorName, documentationMessage];
}
/**
* @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 utils/version
*/
const version = "48.3.0";
const releaseDate = new Date(2026, 5, 2);
/* v8 ignore next -- @preserve */
if (globalThis.CKEDITOR_VERSION)
/**
* This error is thrown when, due to a mistake in the way CKEditor 5 was installed,
* imported, or initialized, some of its modules were evaluated and executed twice.
* Duplicate modules inevitably lead to runtime errors and increased bundle size.
*
* # Check dependency versions
*
* First, make sure that you use the latest version of all CKEditor 5 dependencies.
* Depending on the installation method, you should check the versions of the `ckeditor5`,
* `ckeditor5-premium-features`, or `@ckeditor/ckeditor5-<NAME>` packages. If you cannot update
* to the latest version, ensure that all the CKEditor 5 packages are
* in the same version.
*
* If you use third-party plugins, make sure to update them, too. If they are incompatible
* with the version of CKEditor 5 you use, you may need to downgrade the CKEditor 5 packages
* (which we do not recommend). Ask the plugin's author to upgrade the dependencies,
* or fork their project and update it yourself.
*
* # Check imports
*
* The next step is to look at how you import CKEditor 5 into your project.
*
* **The {@glink updating/nim-migration/migration-to-new-installation-methods new installation methods}
* are designed to prevent module duplication, so if you are not using them yet, you should consider
* updating your project**. However, several legacy installation methods are still supported for backward
* compatibility, and mixing them may result in module duplication.
*
* These are the most common import methods of the CKEditor 5 packages.
*
* - **New installation methods (NIM)** – Imports from the `ckeditor5` and `ckeditor5-premium-features` packages.
* - **Optimized build** for the new installation methods – Imports from the `@ckeditor/ckeditor5-<NAME>/dist/index.js`.
* - **Predefined builds** (no longer supported) – Imports from the `@ckeditor/ckeditor5-build-<NAME>` packages.
* - **Default imports** (legacy) – Imports from the `@ckeditor/ckeditor5-<NAME>` packages (default export).
* - **`src`** (legacy) – Imports from the `@ckeditor/ckeditor5-<NAME>/src/*`.
* - **DLL builds** (legacy) – Imports from the `ckeditor5/build/<NAME>` and `@ckeditor/ckeditor5-<NAME>/build/*`.
*
* The best way to avoid duplicate modules is to avoid mixing these installation methods. For example, if you use imports
* specific to the optimized build, you should use them for all CKEditor 5 packages. In addition, since
* the DLL builds already include the core of the editor, they cannot be used with other types of imports.
*
* Here is a matrix showing which installation methods are compatible with each other:
*
* | | NIM | Optimized build | Predefined builds | Default imports | `src` | DLL builds |
* |------------------|-----|-----------------|-------------------|-----------------|-------|------------|
* | NIM | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
* | Optimized builds | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
* | Predefined build | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
* | Default imports | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
* | `src` | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
* | DLL builds | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
*
* If you use any third-party plugins, make sure the way you import them is compatible with
* the way you import CKEditor 5.
*
* <details>
* <summary>New installation methods and optimized builds</summary>
*
* If you use the {@glink updating/nim-migration/migration-to-new-installation-methods new installation methods},
* you should only import { code } from the `ckeditor5` and `ckeditor5-premium-features` packages.
* Do not import { code } from the `@ckeditor/ckeditor5-<NAME>` packages unless you follow
* the {@glink getting-started/setup/optimizing-build-size Optimizing build size} guide and the imports from
* the `@ckeditor/ckeditor5-<NAME>` packages end with `/dist/index.js`.
*
* If you use a CDN, ensure that some files are not included twice in your project.
*
* Examples of valid and invalid import paths:
*
* ```js
* import { ClassicEditor, Highlight } from 'ckeditor5'; // ✅
* import { Highlight } from '@ckeditor/ckeditor5-highlight/dist/index.js'; // ✅
* import { Highlight } from '@ckeditor/ckeditor5-highlight/src/highlight.js'; // ❌
* import { Highlight } from '@ckeditor/ckeditor5-highlight'; // ❌
* import '@ckeditor/ckeditor5-highlight/build/highlight.js'; // ❌
* ```
* </details>
*
* <details>
* <summary>(Deprecated) Predefined builds</summary>
*
* **As of April, 2025 predefined build are no longer supported. Please refer to the
* {@glink getting-started/index Quick Start} guide
* to choose one of the modern installation and integration methods available**.
*
* If you use the predefined builds, you cannot import any additional plugins.
* These builds already include the editor's core and selected plugins and importing additional
* ones will cause some modules to be bundled and loaded twice.
*
* Examples of valid and invalid import paths:
*
* ```js
* import { ClassicEditor } from '@ckeditor/ckeditor5-build-classic'; // ✅
* import { Highlight } from 'ckeditor5'; // ❌
* import { Highlight } from '@ckeditor/ckeditor5-highlight/dist/index.js'; // ❌
* import { Highlight } from '@ckeditor/ckeditor5-highlight'; // ❌
* import { Highlight } from '@ckeditor/ckeditor5-highlight/src/highlight'; // ❌
* import '@ckeditor/ckeditor5-highlight/build/highlight'; // ❌
* ```
*
* If you are missing some features from the list of plugins, you should switch to the
* {@glink updating/nim-migration/migration-to-new-installation-methods new installation methods}
* which do not have this limitation.
* </details>
*
* <details>
* <summary>(Legacy) Default imports and `src` imports</summary>
*
* If you use the legacy customized installation
* method, you should only import code from the `@ckeditor/ckeditor5-<NAME>` packages. While you can import code from
* the `@ckeditor/ckeditor5-<NAME>/src/*` files, it is not recommended as it can make migration to the new installation
* methods more difficult.
*
* If you use this installation method, you should not import { code } from the `ckeditor5` or `ckeditor5-premium-features` packages.
*
* Examples of valid and invalid import paths:
*
* ```js
* import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic'; // ✅
* import { Highlight } from '@ckeditor/ckeditor5-highlight'; // ✅
* import { Highlight } from '@ckeditor/ckeditor5-highlight/src/highlight.js'; // ✅ (not recommended)
* import { Highlight } from 'ckeditor5'; // ❌
* import { Highlight } from '@ckeditor/ckeditor5-highlight/dist/index.js'; // ❌
* import '@ckeditor/ckeditor5-highlight/build/highlight'; // ❌
* ```
* </details>
*
* <details>
* <summary>(Legacy) DLL builds</summary>
*
* If you are using the {@glink updating/nim-migration/dll-builds legacy DLL builds},
* you should not import any non-DLL modules.
*
* Examples of valid and invalid import paths:
*
* ```js
* import 'ckeditor5/build/ckeditor5-dll.js';// ✅
* import '@ckeditor/ckeditor5-editor-classic/build/editor-classic.js';// ✅
* import '@ckeditor/ckeditor5-highlight/build/highlight.js';// ✅
* import { Highlight } from 'ckeditor5'; // ❌
* import { Highlight } from '@ckeditor/ckeditor5-highlight/dist/index.js'; // ❌
* import { Highlight } from '@ckeditor/ckeditor5-highlight'; // ❌
* import { Highlight } from '@ckeditor/ckeditor5-highlight/src/highlight.js'; // ❌
* ```
* </details>
*
* # Reinstall `node_modules`
*
* Usually, npm and other package managers deduplicate all packages - for example, `ckeditor5` is only installed once
* in `node_modules/`. However, it is known to fail to do so occasionally.
*
* To rule out this possibility, you can try the following:
*
* 1. Remove the `node_modules` directory.
* 2. Remove the `package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml` files (depending on the package manager used).
* 3. Run `npm install` to reinstall all packages.
* 4. Run `npm ls` to check how many times packages like `@ckeditor/ckeditor5-core` are installed.
* If they are installed more than once, verify which package causes that.
*
* @error ckeditor-duplicated-modules
*/
throw new CKEditorError("ckeditor-duplicated-modules", null);
else globalThis.CKEDITOR_VERSION = version;
/**
* @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 utils/emittermixin
*/
const _listeningTo = Symbol("listeningTo");
const _emitterId = Symbol("emitterId");
const _delegations = Symbol("delegations");
const defaultEmitterClass$1 = /* #__PURE__ */ EmitterMixin(Object);
function EmitterMixin(base) {
if (!base) return defaultEmitterClass$1;
class Mixin extends base {
on(event, callback, options) {
this.listenTo(this, event, callback, options);
}
once(event, callback, options) {
let wasFired = false;
const onceCallback = (event, ...args) => {
if (!wasFired) {
wasFired = true;
event.off();
callback.call(this, event, ...args);
}
};
this.listenTo(this, event, onceCallback, options);
}
off(event, callback) {
this.stopListening(this, event, callback);
}
listenTo(emitter, event, callback, options = {}) {
let emitterInfo, eventCallbacks;
if (!this[_listeningTo]) this[_listeningTo] = {};
const emitters = this[_listeningTo];
if (!_getEmitterId(emitter)) _setEmitterId(emitter);
const emitterId = _getEmitterId(emitter);
if (!(emitterInfo = emitters[emitterId])) emitterInfo = emitters[emitterId] = {
emitter,
callbacks: {}
};
if (!(eventCallbacks = emitterInfo.callbacks[event])) eventCallbacks = emitterInfo.callbacks[event] = [];
eventCallbacks.push(callback);
addEventListener(this, emitter, event, callback, options);
}
stopListening(emitter, event, callback) {
const emitters = this[_listeningTo];
let emitterId = emitter && _getEmitterId(emitter);
const emitterInfo = emitters && emitterId ? emitters[emitterId] : void 0;
const eventCallbacks = emitterInfo && event ? emitterInfo.callbacks[event] : void 0;
if (!emitters || emitter && !emitterInfo || event && !eventCallbacks) return;
if (callback) {
removeEventListener(this, emitter, event, callback);
if (eventCallbacks.indexOf(callback) !== -1) if (eventCallbacks.length === 1) delete emitterInfo.callbacks[event];
else removeEventListener(this, emitter, event, callback);
} else if (eventCallbacks) {
while (callback = eventCallbacks.pop()) removeEventListener(this, emitter, event, callback);
delete emitterInfo.callbacks[event];
} else if (emitterInfo) {
for (event in emitterInfo.callbacks) this.stopListening(emitter, event);
delete emitters[emitterId];
} else {
for (emitterId in emitters) this.stopListening(emitters[emitterId].emitter);
delete this[_listeningTo];
}
}
fire(eventOrInfo, ...args) {
try {
const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo(this, eventOrInfo);
const event = eventInfo.name;
let callbacks = getCallbacksForEvent(this, event);
eventInfo.path.push(this);
if (callbacks) {
callbacks = callbacks.slice();
for (let i = 0; i < callbacks.length; i++) {
const fn = callbacks[i].callback;
fn.call(this, eventInfo, ...args);
if (eventInfo.off.called) {
delete eventInfo.off.called;
this._removeEventListener(event, fn);
}
if (eventInfo.stop.called) break;
}
}
const delegations = this[_delegations];
if (delegations) {
const destinations = delegations.get(event);
const passAllDestinations = delegations.get("*");
if (destinations) fireDelegatedEvents(destinations, eventInfo, args);
if (passAllDestinations) fireDelegatedEvents(passAllDestinations, eventInfo, args);
}
return eventInfo.return;
} catch (err) {
/* v8 ignore next -- @preserve */
CKEditorError.rethrowUnexpectedError(err, this);
}
}
delegate(...events) {
return { to: (emitter, nameOrFunction) => {
if (!this[_delegations]) this[_delegations] = /* @__PURE__ */ new Map();
events.forEach((eventName) => {
const destinations = this[_delegations].get(eventName);
if (!destinations) this[_delegations].set(eventName, new Map([[emitter, nameOrFunction]]));
else destinations.set(emitter, nameOrFunction);
});
} };
}
stopDelegating(event, emitter) {
if (!this[_delegations]) return;
if (!event) this[_delegations].clear();
else if (!emitter) this[_delegations].delete(event);
else {
const destinations = this[_delegations].get(event);
if (destinations) destinations.delete(emitter);
}
}
_addEventListener(event, callback, options) {
createEventNamespace(this, event);
const lists = getCallbacksListsForNamespace(this, event);
const callbackDefinition = {
callback,
priority: priorities.get(options.priority)
};
for (const callbacks of lists) insertToPriorityArray(callbacks, callbackDefinition);
}
_removeEventListener(event, callback) {
const lists = getCallbacksListsForNamespace(this, event);
for (const callbacks of lists) for (let i = 0; i < callbacks.length; i++) if (callbacks[i].callback == callback) {
callbacks.splice(i, 1);
i--;
}
}
}
return Mixin;
}
/**
* Checks if `listeningEmitter` listens to an emitter with given `listenedToEmitterId` and if so, returns that emitter.
* If not, returns `null`.
*
* @internal
* @param listeningEmitter An emitter that listens.
* @param listenedToEmitterId Unique emitter id of emitter listened to.
*/
function _getEmitterListenedTo(listeningEmitter, listenedToEmitterId) {
const listeningTo = listeningEmitter[_listeningTo];
if (listeningTo && listeningTo[listenedToEmitterId]) return listeningTo[listenedToEmitterId].emitter;
return null;
}
/**
* Sets emitter's unique id.
*
* **Note:** `_emitterId` can be set only once.
*
* @internal
* @param emitter An emitter for which id will be set.
* @param id Unique id to set. If not passed, random unique id will be set.
*/
function _setEmitterId(emitter, id) {
if (!emitter[_emitterId]) emitter[_emitterId] = id || uid();
}
/**
* Returns emitter's unique id.
*
* @internal
* @param emitter An emitter which id will be returned.
*/
function _getEmitterId(emitter) {
return emitter[_emitterId];
}
/**
* Gets the internal `_events` property of the given object.
* `_events` property store all lists with callbacks for registered event names.
* If there were no events registered on the object, empty `_events` object is created.
*/
function getEvents(source) {
if (!source._events) Object.defineProperty(source, "_events", { value: {} });
return source._events;
}
/**
* Creates event node for generic-specific events relation architecture.
*/
function makeEventNode() {
return {
callbacks: [],
childEvents: []
};
}
/**
* Creates an architecture for generic-specific events relation.
* If needed, creates all events for given eventName, i.e. if the first registered event
* is foo:bar:abc, it will create foo:bar:abc, foo:bar and foo event and tie them together.
* It also copies callbacks from more generic events to more specific events when
* specific events are created.
*/
function createEventNamespace(source, eventName) {
const events = getEvents(source);
if (events[eventName]) return;
let name = eventName;
let childEventName = null;
const newEventNodes = [];
while (name !== "") {
if (events[name]) break;
events[name] = makeEventNode();
newEventNodes.push(events[name]);
if (childEventName) events[name].childEvents.push(childEventName);
childEventName = name;
name = name.substr(0, name.lastIndexOf(":"));
}
if (name !== "") {
for (const node of newEventNodes) node.callbacks = events[name].callbacks.slice();
events[name].childEvents.push(childEventName);
}
}
/**
* Gets an array containing callbacks list for a given event and it's more specific events.
* I.e. if given event is foo:bar and there is also foo:bar:abc event registered, this will
* return callback list of foo:bar and foo:bar:abc (but not foo).
*/
function getCallbacksListsForNamespace(source, eventName) {
const eventNode = getEvents(source)[eventName];
if (!eventNode) return [];
let callbacksLists = [eventNode.callbacks];
for (let i = 0; i < eventNode.childEvents.length; i++) {
const childCallbacksLists = getCallbacksListsForNamespace(source, eventNode.childEvents[i]);
callbacksLists = callbacksLists.concat(childCallbacksLists);
}
return callbacksLists;
}
/**
* Get the list of callbacks for a given event, but only if there any callbacks have been registered.
* If there are no callbacks registered for given event, it checks if this is a specific event and looks
* for callbacks for it's more generic version.
*/
function getCallbacksForEvent(source, eventName) {
if (!source._events) return null;
let currentEventName = eventName;
do {
const event = source._events[currentEventName];
if (event && event.callbacks && event.callbacks.length) return event.callbacks;
const colonIndex = currentEventName.lastIndexOf(":");
currentEventName = colonIndex > -1 ? currentEventName.substring(0, colonIndex) : "";
} while (currentEventName);
return null;
}
/**
* Fires delegated events for given map of destinations.
*
* @param destinations A map containing `[ {@link module:utils/emittermixin~Emitter}, "event name" ]` pair destinations.
* @param eventInfo The original event info object.
* @param fireArgs Arguments the original event was fired with.
*/
function fireDelegatedEvents(destinations, eventInfo, fireArgs) {
for (let [emitter, name] of des