@ckeditor/ckeditor5-utils
Version:
Miscellaneous utilities used by CKEditor 5.
1,193 lines (1,179 loc) • 258 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { isObject, isString, isPlainObject, cloneDeepWith, isElement as isElement$1, isFunction, merge } from 'es-toolkit/compat';
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /* globals window, document */ /**
* @module utils/dom/global
*/ // This interface exists to make our API pages more readable.
/**
* A helper (module) giving an access to the global DOM objects such as `window` and `document`.
*/ /**
* 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/utils';
*
* // This stub will work for any code using global module.
* testUtils.sinon.stub( global, 'window', {
* innerWidth: 10000
* } );
*
* console.log( global.window.innerWidth );
* ```
*/ let globalVar; // named globalVar instead of global: https://github.com/ckeditor/ckeditor5/issues/12971
// In some environments window and document API might not be available.
try {
globalVar = {
window,
document
};
} catch (e) {
// It's not possible to mock a window object to simulate lack of a window object without writing extremely convoluted code.
/* istanbul ignore next -- @preserve */ // Let's cast it to not change module's API.
// We only handle this so loading editor in environments without window and document doesn't fail.
// For better DX we shouldn't introduce mixed types and require developers to check the type manually.
// This module should not be used on purpose in any environment outside browser.
globalVar = {
window: {},
document: {}
};
}
var global = globalVar;
/**
* Safely returns `userAgent` from browser's navigator API in a lower case.
* If navigator API is not available it will return an empty string.
*/ function getUserAgent() {
// In some environments navigator API might not be available.
try {
return navigator.userAgent.toLowerCase();
} catch (e) {
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.
*
* @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.
*
* @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).
*
* @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.
*
* @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.
*
* @param userAgent **Lowercase** `navigator.userAgent` string.
* @returns Whether User Agent is running in iOS or not.
*/ function isiOS(userAgent) {
// "Request mobile site" || "Request desktop site".
return !!userAgent.match(/iphone|ipad/i) || isMac(userAgent) && navigator.maxTouchPoints > 0;
}
/**
* Checks if User Agent represented by the string is Android mobile device.
*
* @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.
*
* @param userAgent **Lowercase** `navigator.userAgent` string.
* @returns Whether User Agent is Blink engine or not.
*/ function isBlink(userAgent) {
// The Edge browser before switching to the Blink engine used to report itself as Chrome (and "Edge/")
// but after switching to the Blink it replaced "Edge/" with "Edg/".
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).
*/ function isRegExpUnicodePropertySupported() {
let isSupported = false;
// Feature detection for Unicode properties. Added in ES2018. Currently Firefox does not support it.
// See https://github.com/ckeditor/ckeditor5-mention/issues/44#issuecomment-487002174.
try {
// Usage of regular expression literal cause error during build (ckeditor/ckeditor5-dev#534).
isSupported = 'ć'.search(new RegExp('[\\p{L}]', 'u')) === 0;
} catch (error) {
// Firefox throws a SyntaxError when the group is unsupported.
}
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.
*/ function isMediaForcedColors() {
return global.window.matchMedia ? global.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.
*/ function isMotionReduced() {
return global.window.matchMedia ? global.window.matchMedia('(prefers-reduced-motion)').matches : false;
}
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @module utils/fastdiff
*/ /**
* 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) {
// Set the comparator function.
cmp = cmp || function(a, b) {
return a === b;
};
// Convert the string (or any array-like object - eg. NodeList) to an array by using the slice() method because,
// unlike Array.from(), it returns array of UTF-16 code units instead of the code points of a string.
// One code point might be a surrogate pair of two code units. All text offsets are expected to be in code units.
// See ckeditor/ckeditor5#3147.
//
// We need to make sure here that fastDiff() works identical to diff().
const arrayA = Array.isArray(a) ? a : Array.prototype.slice.call(a);
const arrayB = Array.isArray(b) ? b : Array.prototype.slice.call(b);
// Find first and last change.
const changeIndexes = findChangeBoundaryIndexes(arrayA, arrayB, cmp);
// Transform into changes array.
const result = atomicChanges ? changeIndexesToAtomicChanges(changeIndexes, arrayB.length) : changeIndexesToChanges(arrayB, changeIndexes);
return result;
}
/**
* 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) {
// Find the first difference between passed values.
const firstIndex = findFirstDifferenceIndex(arr1, arr2, cmp);
// If arrays are equal return -1 indexes object.
if (firstIndex === -1) {
return {
firstIndex: -1,
lastIndexOld: -1,
lastIndexNew: -1
};
}
// Remove the common part of each value and reverse them to make it simpler to find the last difference between them.
const oldArrayReversed = cutAndReverse(arr1, firstIndex);
const newArrayReversed = cutAndReverse(arr2, firstIndex);
// Find the first difference between reversed values.
// It should be treated as "how many elements from the end the last difference occurred".
//
// For example:
//
// initial -> after cut -> reversed:
// oldValue: '321ba' -> '21ba' -> 'ab12'
// newValue: '31xba' -> '1xba' -> 'abx1'
// lastIndex: -> 2
//
// So the last change occurred two characters from the end of the arrays.
const lastIndex = findFirstDifferenceIndex(oldArrayReversed, newArrayReversed, cmp);
// Use `lastIndex` to calculate proper offset, starting from the beginning (`lastIndex` kind of starts from the end).
const lastIndexOld = arr1.length - lastIndex;
const lastIndexNew = arr2.length - lastIndex;
return {
firstIndex,
lastIndexOld,
lastIndexNew
};
}
/**
* 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] === undefined || arr2[i] === undefined || !cmp(arr1[i], arr2[i])) {
return i;
}
}
return -1; // Return -1 if arrays are equal.
}
/**
* 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;
// Order operations as 'insert', 'delete' array to keep compatibility with {@link module:utils/difftochanges~diffToChanges}
// in most cases. However, 'diffToChanges' does not stick to any order so in some cases
// (for example replacing '12345' with 'abcd') it will generate 'delete', 'insert' order.
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;
// No changes.
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;
}
// The following code is based on the "O(NP) Sequence Comparison Algorithm"
// by Sun Wu, Udi Manber, Gene Myers, Webb Miller.
/**
* 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.
*/ function diff(a, b, cmp) {
// Set the comparator function.
cmp = cmp || function(a, b) {
return a === b;
};
const aLength = a.length;
const bLength = b.length;
// Perform `fastDiff` for longer strings/arrays (see #269).
if (aLength > 200 || bLength > 200 || aLength + bLength > 300) {
return diff.fastDiff(a, b, cmp, true);
}
// Temporary action type statics.
let _insert, _delete;
// Swapped the arrays to use the shorter one as the first one.
if (bLength < aLength) {
const tmp = a;
a = b;
b = tmp;
// We swap the action types as well.
_insert = 'delete';
_delete = 'insert';
} else {
_insert = 'insert';
_delete = 'delete';
}
const m = a.length;
const n = b.length;
const delta = n - m;
// Edit scripts, for each diagonal.
const es = {};
// Furthest points, the furthest y we can get on each diagonal.
const fp = {};
function snake(k) {
// We use -1 as an alternative below to handle initial values ( instead of filling the fp with -1 first ).
// Furthest points (y) on the diagonal below k.
const y1 = (fp[k - 1] !== undefined ? fp[k - 1] : -1) + 1;
// Furthest points (y) on the diagonal above k.
const y2 = fp[k + 1] !== undefined ? fp[k + 1] : -1;
// The way we should go to get further.
const dir = y1 > y2 ? -1 : 1;
// Clone previous changes array (if any).
if (es[k + dir]) {
es[k] = es[k + dir].slice(0);
}
// Create changes array.
if (!es[k]) {
es[k] = [];
}
// Push the action.
es[k].push(y1 > y2 ? _insert : _delete);
// Set the beginning coordinates.
let y = Math.max(y1, y2);
let x = y - k;
// Traverse the diagonal as long as the values match.
while(x < m && y < n && cmp(a[x], b[y])){
x++;
y++;
// Push no change action.
es[k].push('equal');
}
return y;
}
let p = 0;
let k;
// Traverse the graph until we reach the end of the longer string.
do {
// Updates furthest points and edit scripts for diagonals below delta.
for(k = -p; k < delta; k++){
fp[k] = snake(k);
}
// Updates furthest points and edit scripts for diagonals above delta.
for(k = delta + p; k > delta; k--){
fp[k] = snake(k);
}
// Updates furthest point and edit script for the delta diagonal.
// note that the delta diagonal is the one which goes through the sink (m, n).
fp[delta] = snake(delta);
p++;
}while (fp[delta] !== n)
// Return the final list of edit changes.
// We remove the first item that represents the action for the injected nulls.
return es[delta].slice(1);
}
// Store the API in static property to easily overwrite it in tests.
// Too bad dependency injection does not work in Webpack + ES 6 (const) + Babel.
diff.fastDiff = fastDiff;
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @module utils/difftochanges
*/ /**
* 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 ( change == 'delete' ) */ {
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-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @module utils/mix
*/ /**
* Copies enumerable properties and symbols from the objects given as 2nd+ parameters to the
* prototype of first object (a constructor).
*
* ```
* class Editor {
* ...
* }
*
* const SomeMixin = {
* a() {
* return 'a';
* }
* };
*
* mix( Editor, SomeMixin, ... );
*
* new Editor().a(); // -> 'a'
* ```
*
* Note: Properties which already exist in the base class will not be overriden.
*
* @deprecated Use mixin pattern, see: https://www.typescriptlang.org/docs/handbook/mixins.html.
* @param baseClass Class which prototype will be extended.
* @param mixins Objects from which to get properties.
*/ function mix(baseClass, ...mixins) {
mixins.forEach((mixin)=>{
const propertyNames = Object.getOwnPropertyNames(mixin);
const propertySymbols = Object.getOwnPropertySymbols(mixin);
propertyNames.concat(propertySymbols).forEach((key)=>{
if (key in baseClass.prototype) {
return;
}
if (typeof mixin == 'function' && (key == 'length' || key == 'name' || key == 'prototype')) {
return;
}
const sourceDescriptor = Object.getOwnPropertyDescriptor(mixin, key);
sourceDescriptor.enumerable = false;
Object.defineProperty(baseClass.prototype, key, sourceDescriptor);
});
});
}
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @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;
};
}
/**
* The event object passed to event callbacks. It is used to provide information about the event as well as a tool to
* manipulate it.
*/ class EventInfo {
/**
* 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 = [];
// The following methods are defined in the constructor because they must be re-created per instance.
this.stop = spy();
this.off = spy();
}
}
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @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() {
// Let's create some positive random 32bit integers first.
const [r1, r2, r3, r4] = crypto.getRandomValues(new Uint32Array(4));
// Make sure that id does not start with number.
return 'e' + HEX_NUMBERS[r1 >> 0 & 0xFF] + HEX_NUMBERS[r1 >> 8 & 0xFF] + HEX_NUMBERS[r1 >> 16 & 0xFF] + HEX_NUMBERS[r1 >> 24 & 0xFF] + HEX_NUMBERS[r2 >> 0 & 0xFF] + HEX_NUMBERS[r2 >> 8 & 0xFF] + HEX_NUMBERS[r2 >> 16 & 0xFF] + HEX_NUMBERS[r2 >> 24 & 0xFF] + HEX_NUMBERS[r3 >> 0 & 0xFF] + HEX_NUMBERS[r3 >> 8 & 0xFF] + HEX_NUMBERS[r3 >> 16 & 0xFF] + HEX_NUMBERS[r3 >> 24 & 0xFF] + HEX_NUMBERS[r4 >> 0 & 0xFF] + HEX_NUMBERS[r4 >> 8 & 0xFF] + HEX_NUMBERS[r4 >> 16 & 0xFF] + HEX_NUMBERS[r4 >> 24 & 0xFF];
}
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @module utils/priorities
*/ /**
* String representing a priority value.
*/ /**
* 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: 100000,
high: 1000,
normal: 0,
low: -1e3,
lowest: -1e5
};
/**
* 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);
// Binary search for better performance in large tables.
let left = 0;
let right = objects.length;
while(left < right){
const mid = left + right >> 1; // Use bitwise operator for faster floor division by 2.
const midPriority = priorities.get(objects[mid].priority);
if (midPriority < priority) {
right = mid;
} else {
left = mid + 1;
}
}
objects.splice(left, 0, objectToInsert);
}
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @module utils/ckeditorerror
*/ /* globals console */ /**
* 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'
* } );
* ```
*/ 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.
*/ constructor(errorName, context, data){
super(getErrorMessage(errorName, data));
this.name = 'CKEditorError';
this.context = context;
this.data = data;
}
/**
* 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 err 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(err, context) {
if (err.is && err.is('CKEditorError')) {
throw err;
}
/**
* 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
*/ const error = new CKEditorError(err.message, context);
// Restore the original stack trace to make the error look like the original one.
// See https://github.com/ckeditor/ckeditor5/issues/5595 for more details.
error.stack = err.stack;
throw 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) {
const processedObjects = 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);
return errorName + stringifiedData + documentationLink;
}
/**
* Returns formatted console error arguments.
*/ function formatConsoleArguments(errorName, data) {
const documentationMessage = getLinkToDocumentationMessage(errorName);
return data ? [
errorName,
data,
documentationMessage
] : [
errorName,
documentationMessage
];
}
const version = '45.1.0';
// The second argument is not a month. It is `monthIndex` and starts from `0`.
const releaseDate = new Date(2025, 4, 14);
/* istanbul 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 {@glink getting-started/legacy/installation-methods/quick-start-other 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 getting-started/legacy/advanced/alternative-setups/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;
}
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)=>{
// Ensure the callback is called only once even if the callback itself leads to re-firing the event
// (which would call the callback again).
if (!wasFired) {
wasFired = true;
// Go off() at the first call.
event.off();
// Go with the original callback.
callback.call(this, event, ...args);
}
};
// Make a similar on() call, simply replacing the callback.
this.listenTo(this, event, onceCallback, options);
}
off(event, callback) {
this.stopListening(this, event, callback);
}
listenTo(emitter, event, callback, options = {}) {
let emitterInfo, eventCallbacks;
// _listeningTo contains a list of emitters that this object is listening to.
// This list has the following format:
//
// _listeningTo: {
// emitterId: {
// emitter: emitter,
// callbacks: {
// event1: [ callback1, callback2, ... ]
// ....
// }
// },
// ...
// }
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);
// Finally register the callb