diff --git a/CHANGES.md b/CHANGES.md index dbd3480255e..64cecdde1e7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,8 @@ CKEditor 4 Changelog New Features: +* [#4461](https://github.com/ckeditor/ckeditor4/issues/4461): Introduced possibility to delay editor initialization while it is in detached DOM element. + Fixed Issues: * [#4604](https://github.com/ckeditor/ckeditor4/issues/4604): Fixed: [`CKEDITOR.plugins.clipboard.dataTransfer#getTypes()`](https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_plugins_clipboard_dataTransfer.html#method-getTypes) returns no types. diff --git a/core/creators/inline.js b/core/creators/inline.js index 7321e531b3d..862d935389f 100644 --- a/core/creators/inline.js +++ b/core/creators/inline.js @@ -35,11 +35,18 @@ return null; } - var editor = new CKEDITOR.editor( instanceConfig, element, CKEDITOR.ELEMENT_MODE_INLINE ), - textarea = element.is( 'textarea' ) ? element : null; + // (#4461) + if ( CKEDITOR.editor.shouldDelayEditorCreation( element, instanceConfig ) ) { + CKEDITOR.editor.initializeDelayedEditorCreation( element, instanceConfig, 'inline' ); + return null; + } + + var textarea = element.is( 'textarea' ) ? element : null, + editorData = textarea ? textarea.getValue() : element.getHtml(), + editor = new CKEDITOR.editor( instanceConfig, element, CKEDITOR.ELEMENT_MODE_INLINE ); if ( textarea ) { - editor.setData( textarea.getValue(), null, true ); + editor.setData( editorData, null, true ); //Change element from textarea to div element = CKEDITOR.dom.element.createFromHtml( @@ -63,7 +70,7 @@ // Initial editor data is simply loaded from the page element content to make // data retrieval possible immediately after the editor creation. - editor.setData( element.getHtml(), null, true ); + editor.setData( editorData, null, true ); } // Once the editor is loaded, start the UI. @@ -156,6 +163,7 @@ CKEDITOR.domReady( function() { !CKEDITOR.disableAutoInline && CKEDITOR.inlineAll(); } ); + } )(); /** diff --git a/core/creators/themedui.js b/core/creators/themedui.js index 94f64f1b553..4df16f428b2 100644 --- a/core/creators/themedui.js +++ b/core/creators/themedui.js @@ -343,6 +343,12 @@ CKEDITOR.replaceClass = 'ckeditor'; return null; } + // (#4461) + if ( CKEDITOR.editor.shouldDelayEditorCreation( element, config ) ) { + CKEDITOR.editor.initializeDelayedEditorCreation( element, config, 'replace' ); + return null; + } + // Create the editor instance. var editor = new CKEDITOR.editor( config, element, mode ); diff --git a/core/editor.js b/core/editor.js index 72567c3b17a..26a868ec64e 100644 --- a/core/editor.js +++ b/core/editor.js @@ -1648,6 +1648,103 @@ return element; }; + + /** + * Initializes delayed editor creation based on provided configuration. + * + * If the {@link CKEDITOR.config#delayIfDetached_callback} function is declared, it will be invoked with a single argument: + * + * * A callback, that should be called to create editor. + * + * Otherwise, it periodically (with `setInterval()` calls) checks if element is attached to DOM and creates editor automatically. + * + * ```js + * CKEDITOR.inline( detachedEditorElement, { + * delayIfDetached: true, + * delayIfDetached_callback: registerCallback + * } ); + * ``` + * + * @private + * @since 4.17.0 + * @static + * @member CKEDITOR.editor + * @param {CKEDITOR.dom.element} element The DOM element on which editor should be initialized. + * @param {Object} config The specific configuration to apply to the editor instance. Configuration set here will override the global CKEditor settings. + * @param {String} editorCreationMethod Creator function that should be used to initialize editor (inline/replace). + */ + CKEDITOR.editor.initializeDelayedEditorCreation = function( element, config, editorCreationMethod ) { + if ( config.delayIfDetached_callback ) { + CKEDITOR.warn( 'editor-delayed-creation', { + method: 'callback' + } ); + + config.delayIfDetached_callback( function() { + CKEDITOR[ editorCreationMethod ]( element, config ); + + CKEDITOR.warn( 'editor-delayed-creation-success', { + method: 'callback' + } ); + } ); + } else { + var interval = config.delayIfDetached_interval === undefined ? CKEDITOR.config.delayIfDetached_interval : config.delayIfDetached_interval, + intervalId; + + CKEDITOR.warn( 'editor-delayed-creation', { + method: 'interval - ' + interval + ' ms' + } ); + + intervalId = setInterval( function() { + if ( !element.isDetached() ) { + clearInterval( intervalId ); + + CKEDITOR[ editorCreationMethod ]( element, config ); + + CKEDITOR.warn( 'editor-delayed-creation-success', { + method: 'interval - ' + interval + ' ms' + } ); + } + }, interval ); + } + }; + + /** + * Whether editor creation should be delayed. + * + * @private + * @since 4.17.0 + * @static + * @member CKEDITOR.editor + * @param {CKEDITOR.dom.element} element The DOM element on which editor should be initialized. + * @param {Object} config Editor configuration. + * @returns {Boolean} True if creation should be delayed. + */ + CKEDITOR.editor.shouldDelayEditorCreation = function( element, config ) { + CKEDITOR.editor.mergeDelayedCreationConfigs( config ); + return config && config.delayIfDetached && element.isDetached(); + }; + + /** + * Merges user provided configuration options for delayed creation with {@link CKEDITOR.config default config}. + * + * User provided options are the preferred ones. + * + * @private + * @since 4.17.0 + * @static + * @member CKEDITOR.editor + * @param {Object} userConfig Config provided by the user to create editor. + */ + CKEDITOR.editor.mergeDelayedCreationConfigs = function( userConfig ) { + if ( !userConfig ) { + return; + } + + userConfig.delayIfDetached = typeof userConfig.delayIfDetached === 'boolean' ? userConfig.delayIfDetached : CKEDITOR.config.delayIfDetached; + userConfig.delayIfDetached_interval = isNaN( userConfig.delayIfDetached_interval ) ? CKEDITOR.config.delayIfDetached_interval : userConfig.delayIfDetached_interval; + userConfig.delayIfDetached_callback = userConfig.delayIfDetached_callback || CKEDITOR.config.delayIfDetached_callback; + }; + } )(); /** @@ -2228,3 +2325,61 @@ CKEDITOR.ELEMENT_MODE_INLINE = 3; * @event contentDomInvalidated * @param {CKEDITOR.editor} editor This editor instance. */ + +/** + * If set to `true`, editor will be only created when its root element is attached to DOM. + * In case the element is detached, the editor will wait for the element to be attached and initialized then. + * + * For more control over the entire process refer to {@link CKEDITOR.config#delayIfDetached_callback} + * and {@link CKEDITOR.config#delayIfDetached_interval} configuration options. + * + * config.delayIfDetached = true; + * + * @since 4.17.0 + * @cfg {Boolean} [delayIfDetached=false] + * @member CKEDITOR.config + */ +CKEDITOR.config.delayIfDetached = false; + +/** + * Function used to initialize delayed editor creation. + * + * It accepts a single `callback` argument. A `callback` argument is another function that triggers editor creation. + * This allows to store the editor creation function (`callback`) and invoke it whenever necessary instead of periodically + * check if element is attached to DOM to improve performance. + * + * Used only if {@link CKEDITOR.config#delayIfDetached} is set to `true`. + * + * **Note**: This function (`callback`) should be called only if editor target element is reattached to DOM. + * + * If this option is defined, editor will not run the default {@link CKEDITOR.config#delayIfDetached_interval interval checks}. + * + * // Store the reference to the editor creation function. + * var resumeEditorCreation; + * + * config.delayIfDetached_callback = function( createEditor ) { + * resumeEditorCreation = createEditor; + * }; + * + * // Create editor calling `resumeEditorCreation()` whenever you choose (e.g. on button click). + * resumeEditorCreation(); + * + * @since 4.17.0 + * @cfg {Function} [delayIfDetached_callback = undefined] + * @member CKEDITOR.config + */ +CKEDITOR.config.delayIfDetached_callback = undefined; + +/** + * The amount of time (in milliseconds) between consecutive checks whether editor's target element is attached to DOM. + * + * Used only if {@link CKEDITOR.config#delayIfDetached} is set to `true` and + * {@link CKEDITOR.config#delayIfDetached_callback delayIfDetached_callback} not set. + * + * config.delayIfDetached_interval = 2000; // Try to create editor every 2 seconds. + * + * @since 4.17.0 + * @cfg {Number} [delayIfDetached_interval=50] + * @member CKEDITOR.config + */ +CKEDITOR.config.delayIfDetached_interval = 50; diff --git a/tests/core/creators/_helpers/tools.js b/tests/core/creators/_helpers/tools.js new file mode 100644 index 00000000000..f65350ed629 --- /dev/null +++ b/tests/core/creators/_helpers/tools.js @@ -0,0 +1,240 @@ +/* exported detachedTests */ + +'use strict'; + +var detachedTests = ( function() { + function appendTests( creatorFunction, tests ) { + + return CKEDITOR.tools.extend( tests, { + test_editor_is_created_immediately_on_not_detached_element_even_with_delay_config: function() { + var editorElement = CKEDITOR.document.getById( createHtmlForEditor() ), + editor = CKEDITOR[ creatorFunction ]( editorElement, { + delayIfDetached: true, + delayIfDetached_callback: function() {} + } ); + + assert.isNotNull( editor, 'Editor should be created immediately on not detached element, even if config allows a delay. Creator function used: ' + creatorFunction ); + }, + + test_editor_is_created_immediately_on_not_detached_element_with_delayIfDetached_config_set_as_false: function() { + var editorElement = CKEDITOR.document.getById( createHtmlForEditor() ), + editor = CKEDITOR[ creatorFunction ]( editorElement, { + delayIfDetached: false + } ); + + assert.isNotNull( editor, 'Editor should be created immediately on not detached element, despite config delay option. Creator function used: ' + creatorFunction ); + }, + + test_editor_without_config_is_created_immediately_on_not_detached_element: function() { + var editorElement = CKEDITOR.document.getById( createHtmlForEditor() ), + editor = CKEDITOR[ creatorFunction ]( editorElement ); + + assert.isNotNull( editor, 'Editor should be created immediately with default config options. Creator function used: ' + creatorFunction ); + }, + + test_delay_editor_creation_if_target_element_is_detached: function() { + var editorElement = CKEDITOR.document.getById( createHtmlForEditor() ), + editorParent = editorElement.getParent(); + + editorElement.remove(); + + var editor = CKEDITOR[ creatorFunction ]( editorElement, { + delayIfDetached: true + } ); + + assert.isNull( editor, 'Editor should not be created on detached element, if config allows a delay. Creator function used: ' + creatorFunction ); + + editorParent.append( editorElement ); + }, + + test_delay_editor_creation_with_default_interval_strategy_until_target_element_attach_to_DOM: function() { + var editorElement = CKEDITOR.document.getById( createHtmlForEditor() ), + editorParent = editorElement.getParent(); + + editorElement.remove(); + + CKEDITOR[ creatorFunction ]( editorElement, { + delayIfDetached: true, + on: { + instanceReady: function() { + resume( function() { + assert.pass( 'Editor was created. Creator function used: ' + creatorFunction ); + } ); + } + } + } ); + + CKEDITOR.tools.setTimeout( function() { + editorParent.append( editorElement ); + }, 250 ); + + wait(); + }, + + test_editor_creation_from_provided_callback: function() { + var editorElement = CKEDITOR.document.getById( createHtmlForEditor() ), + editorParent = editorElement.getParent(), + editorCreationCallback; + + editorElement.remove(); + + CKEDITOR[ creatorFunction ]( editorElement, { + delayIfDetached: true, + delayIfDetached_callback: registerCallback, + on: { + instanceReady: function() { + resume( function() { + assert.pass( 'Editor was created from custom callback. Creator function used: ' + creatorFunction ); + } ); + } + } + } ); + + function registerCallback( editorCreationFunc ) { + editorCreationCallback = editorCreationFunc; + } + + CKEDITOR.tools.setTimeout( function() { + editorParent.append( editorElement ); + editorCreationCallback(); + }, 250 ); + + wait(); + }, + + test_editor_default_delay_creation_invokes_CKEDITOR_warn: function() { + var spyWarn = sinon.spy(), + editorElement = CKEDITOR.document.getById( createHtmlForEditor() ), + editorParent = editorElement.getParent(); + + editorElement.remove(); + + CKEDITOR.on( 'log', spyWarn ); + + CKEDITOR[ creatorFunction ]( editorElement, { + delayIfDetached: true, + on: { + instanceReady: function() { + resume( function() { + var firstCallData = spyWarn.firstCall.args[ 0 ].data, + secondCallData = spyWarn.secondCall.args[ 0 ].data, + expectedMethod = 'interval - ' + CKEDITOR.config.delayIfDetached_interval + ' ms'; + + assert.areEqual( 'editor-delayed-creation', firstCallData.errorCode, 'First editor warn should be about creation delay with interval. Creator function used: ' + creatorFunction ); + assert.areEqual( expectedMethod , firstCallData.additionalData.method, 'First editor warn method should be interval with time. Creator function used: ' + creatorFunction ); + + assert.areEqual( + 'editor-delayed-creation-success', + secondCallData.errorCode, + 'Second editor warn should be about success editor creation with interval. Creator function used: ' + creatorFunction + ); + assert.areEqual( + expectedMethod, + secondCallData.additionalData.method, + 'Second editor warn method should be interval with time. Creator function used: ' + creatorFunction + ); + + CKEDITOR.removeListener( 'log', spyWarn ); + } ); + } + } + } ); + + CKEDITOR.tools.setTimeout( function() { + editorParent.append( editorElement ); + }, 250 ); + + wait(); + }, + + test_editor_delay_creation_with_callback_invokes_CKEDITOR_warn: function() { + var spyWarn = sinon.spy(), + editorElement = CKEDITOR.document.getById( createHtmlForEditor() ), + editorParent = editorElement.getParent(), + resumeEditorCreation; + + editorElement.remove(); + + CKEDITOR.on( 'log', spyWarn ); + + function delayedCallback( createEditorFunction ) { + resumeEditorCreation = createEditorFunction; + } + + CKEDITOR[ creatorFunction ]( editorElement, { + delayIfDetached: true, + delayIfDetached_callback: delayedCallback, + on: { + instanceReady: function() { + resume( function() { + var firstCallData = spyWarn.firstCall.args[ 0 ].data, + secondCallData = spyWarn.secondCall.args[ 0 ].data; + + assert.areEqual( 'editor-delayed-creation', firstCallData.errorCode, 'First editor warn should be about creation delay with callback. Creator function used: ' + creatorFunction ); + assert.areEqual( 'callback' , firstCallData.additionalData.method, 'First editor warn method should be \'callback\'. Creator function used: ' + creatorFunction ); + + assert.areEqual( + 'editor-delayed-creation-success', + secondCallData.errorCode, + 'Second editor warn should be about success editor creation with callback. Creator function used: ' + creatorFunction + ); + assert.areEqual( + 'callback', + secondCallData.additionalData.method, + 'Second editor warn method should be \'callback\'. Creator function used: ' + creatorFunction + ); + + CKEDITOR.removeListener( 'log', spyWarn ); + } ); + } + } + } ); + + CKEDITOR.tools.setTimeout( function() { + editorParent.append( editorElement ); + + resumeEditorCreation(); + }, 250 ); + + wait(); + }, + + test_editor_interval_attempts_to_create_if_target_element_is_detached: function() { + var editorElement = CKEDITOR.document.getById( createHtmlForEditor() ), + editorElementParent = editorElement.getParent(), + spyIsDetached = sinon.spy( editorElement, 'isDetached' ); + + editorElement.remove(); + + CKEDITOR[ creatorFunction ]( editorElement, { + delayIfDetached: true, + delayIfDetached_interval: 50 + } ); + + CKEDITOR.tools.setTimeout( function() { + resume( function() { + editorElementParent.append( editorElement ); + assert.isTrue( spyIsDetached.callCount > 2, 'There should be at least three calls of isDetached(). Creator function used: ' + creatorFunction ); + spyIsDetached.restore(); + } ); + }, 200 ); + + wait(); + } + } ); + } + + function createHtmlForEditor() { + var editorId = 'editor' + new Date().getTime(), + editorSlot = CKEDITOR.dom.element.createFromHtml( '
Content!!
Lorem ipsum dolor sit amet.
+Lorem ipsum dolor sit amet.
+Lorem ipsum dolor sit amet.
+Lorem ipsum dolor sit amet.
+Lorem ipsum dolor sit amet.
+