UNPKG

@ckeditor/ckeditor5-utils

Version:

Miscellaneous utilities used by CKEditor 5.

1,337 lines (1,323 loc) 210 kB
/** * @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&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 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