UNPKG

@ckeditor/ckeditor5-utils

Version:

Miscellaneous utilities used by CKEditor 5.

1,193 lines (1,179 loc) 258 kB
/** * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ import { 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&nbsp;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&nbsp;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&nbsp;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&nbsp;5 you use, you may need to downgrade the CKEditor&nbsp;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&nbsp;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&nbsp;5 packages. * * - **New installation methods (NIM)** &ndash; Imports from the `ckeditor5` and `ckeditor5-premium-features` packages. * - **Optimized build** for the new installation methods &ndash; Imports from the `@ckeditor/ckeditor5-<NAME>/dist/index.js`. * - **Predefined builds** (no longer supported) &ndash; Imports from the `@ckeditor/ckeditor5-build-<NAME>` packages. * - **Default imports** (legacy) &ndash; Imports from the `@ckeditor/ckeditor5-<NAME>` packages (default export). * - **`src`** (legacy) &ndash; Imports from the `@ckeditor/ckeditor5-<NAME>/src/*`. * - **DLL builds** (legacy) &ndash; 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&nbsp;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&nbsp;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