diff --git a/base/inc/fields/js/date-range-field.js b/base/inc/fields/js/date-range-field.js index fd7cdbbfd..29a9302bf 100644 --- a/base/inc/fields/js/date-range-field.js +++ b/base/inc/fields/js/date-range-field.js @@ -1,6 +1,44 @@ -/* global jQuery, pikaday */ +( function( factory ) { + let initialized = false; + const bootstrap = function() { + if ( initialized || typeof window.jQuery === 'undefined' ) { + return initialized; + } + + initialized = true; + factory( window.jQuery ); + + return true; + }; + + if ( ! bootstrap() ) { + let attempts = 0; + const interval = setInterval( function() { + attempts++; + + if ( bootstrap() || attempts >= 40 ) { + clearInterval( interval ); + } + }, 50 ); + + document.addEventListener( 'DOMContentLoaded', bootstrap ); + } +} )( function( $ ) { + let dateRangeSetupAttempts = 0; + let dateRangeSetupScheduled = false; + const scheduleInitializeDateRangeFields = function() { + if ( dateRangeSetupScheduled || dateRangeSetupAttempts >= 40 ) { + return; + } + + dateRangeSetupAttempts++; + dateRangeSetupScheduled = true; + setTimeout( function() { + dateRangeSetupScheduled = false; + initializeDateRangeFields(); + }, 100 ); + }; -( function( $ ) { const setupDateRangeField = function( e ) { const $dateRangeField = $( this ); const valField = $dateRangeField.find( 'input[type="hidden"][class="siteorigin-widget-input"]' ); @@ -10,6 +48,10 @@ } if ( $dateRangeField.find( '[class*="sowb-specific-date"]' ).length > 0 ) { + if ( typeof window.Pikaday === 'undefined' ) { + scheduleInitializeDateRangeFields(); + return; + } const createPikadayInput = function( inputName, initVal ) { const $field = $dateRangeField.find( '.' + inputName + '-picker' ); @@ -44,7 +86,7 @@ valField.trigger( 'change', { silent: true } ); }; - const picker = new Pikaday( { + const picker = new window.Pikaday( { field: $field[0], blurFieldOnSelect: false, toString: dateToString, @@ -125,12 +167,50 @@ $( document ).on( 'sowsetupformfield', '.siteorigin-widget-field-type-date-range', setupDateRangeField ); } + const initializeDateRangeFields = function() { + $( '.siteorigin-widget-field-type-date-range' ).each( function() { + setupDateRangeField.call( this ); + } ); + }; + // Add support for the Site Editor. window.addEventListener( 'message', function( e ) { if ( e.data && e.data.action === 'sowbBlockFormInit' ) { - $( '.siteorigin-widget-field-type-date-range' ).each( function() { - setupDateRangeField.call( this ); - } ); + initializeDateRangeFields(); } } ); -} )( jQuery ); \ No newline at end of file + + if ( window.frameElement ) { + $( document ).on( 'sowsetupformfield', '.siteorigin-widget-field-type-date-range', setupDateRangeField ); + $( initializeDateRangeFields ); + + if ( window.MutationObserver ) { + $( function() { + if ( ! document.body ) { + return; + } + + const observer = new MutationObserver( ( mutations ) => { + mutations.forEach( ( mutation ) => { + $( mutation.addedNodes ).each( function() { + const $node = $( this ); + + if ( $node.is( '.siteorigin-widget-field-type-date-range' ) ) { + setupDateRangeField.call( this ); + } + + $node.find( '.siteorigin-widget-field-type-date-range' ).each( function() { + setupDateRangeField.call( this ); + } ); + } ); + } ); + } ); + + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + } ); + } + } +} ); diff --git a/base/inc/fields/js/tinymce-field.js b/base/inc/fields/js/tinymce-field.js index fd461b45a..051615ef8 100644 --- a/base/inc/fields/js/tinymce-field.js +++ b/base/inc/fields/js/tinymce-field.js @@ -48,6 +48,132 @@ mediaFrame.open(); }; + /** + * Clears any pending TinyMCE setup state from a field. + * + * @param {jQuery} $field - jQuery object of the field container element. + */ + const clearTinyMCEFieldPendingSetup = function( $field ) { + const visibilityPoll = $field.data( 'sowb-tinymce-visibility-poll' ); + if ( visibilityPoll ) { + clearInterval( visibilityPoll ); + $field.removeData( 'sowb-tinymce-visibility-poll' ); + } + + $field.removeData( 'sowb-pre-init-bound' ); + $field.removeAttr( 'data-pre-init' ); + }; + + /** + * Removes an existing TinyMCE editor instance when the runtime exposes a + * compatible teardown API. + * + * WordPress can expose `wp.oldEditor` in iframe contexts for legacy + * compatibility, but that object does not always implement `remove()`. + * Prefer the active editor API when available and fall back to the + * TinyMCE instance directly. + * + * @param {Object} wpEditor - The resolved WordPress editor API object. + * @param {string} id - The editor textarea ID. + */ + const removeTinyMCEEditor = function( wpEditor, id ) { + if ( wpEditor && typeof wpEditor.remove === 'function' ) { + wpEditor.remove( id ); + return; + } + + if ( window.wp && window.wp.editor && typeof window.wp.editor.remove === 'function' ) { + window.wp.editor.remove( id ); + return; + } + + if ( window.tinymce ) { + const editor = window.tinymce.get( id ); + if ( editor && typeof editor.remove === 'function' ) { + editor.remove(); + } + } + }; + + /** + * Resolve the textarea and editor id for a TinyMCE field. + * + * @param {jQuery} $field - jQuery object of the field container element. + * + * @returns {Object} The textarea and current editor id. + */ + const getTinyMCEFieldEditor = function( $field ) { + const $textarea = $field + .find( '.siteorigin-widget-tinymce-container textarea.wp-editor-area, .siteorigin-widget-tinymce-container textarea' ) + .first(); + + return { + $textarea, + id: $textarea.attr( 'data-tinymce-id' ) || $textarea.data( 'tinymce-id' ) || $textarea.attr( 'id' ) + }; + }; + + /** + * Checks whether a field has a usable editor instance or TinyMCE chrome. + * + * @param {jQuery} $field - jQuery object of the field container element. + * @param {string} id - The editor textarea ID. + * + * @returns {boolean} True when the TinyMCE field is already healthy. + */ + const hasHealthyTinyMCEEditor = function( $field, id ) { + if ( ! id ) { + return false; + } + + if ( window.tinymce && window.tinymce.get( id ) ) { + return true; + } + + const fieldElement = $field.get( 0 ); + const editorIframe = document.getElementById( id + '_ifr' ); + if ( editorIframe && fieldElement && fieldElement.contains( editorIframe ) ) { + return true; + } + + const editorWrap = document.getElementById( 'wp-' + id + '-wrap' ); + if ( + editorWrap && + fieldElement && + fieldElement.contains( editorWrap ) && + $( editorWrap ).find( '.mce-tinymce, .mce-toolbar-grp' ).length > 0 + ) { + return true; + } + + return false; + }; + + /** + * Removes partial TinyMCE markup while keeping the textarea/content intact. + * + * @param {jQuery} $field - jQuery object of the field container element. + * @param {string} id - The editor textarea ID. + */ + const removeStaleTinyMCEFieldState = function( $field, id ) { + if ( ! id ) { + return; + } + + const wpEditor = window.wp ? ( window.wp.oldEditor ? window.wp.oldEditor : window.wp.editor ) : null; + + removeTinyMCEEditor( wpEditor, id ); + + const editorWrap = document.getElementById( 'wp-' + id + '-wrap' ); + if ( editorWrap ) { + const $textarea = $( document.getElementById( id ) ); + if ( $textarea.length ) { + $( editorWrap ).after( $textarea ); + } + $( editorWrap ).remove(); + } + }; + /** * Sets up a TinyMCE field within a widget form. * Handles initialization of the TinyMCE editor, event binding, and UI setup. @@ -56,9 +182,18 @@ */ const setupTinyMCEField = function( $field ) { if ( $field.attr( 'data-initialized' ) ) { - return; + const initializedEditor = getTinyMCEFieldEditor( $field ); + + if ( hasHealthyTinyMCEEditor( $field, initializedEditor.id ) ) { + return; + } + + clearTinyMCEFieldPendingSetup( $field ); + removeStaleTinyMCEFieldState( $field, initializedEditor.id ); + $field.removeAttr( 'data-initialized' ); } + clearTinyMCEFieldPendingSetup( $field ); $field.attr( 'data-initialized', true ); // If this is in an iframe, copy necessary globals from the parent window. @@ -107,13 +242,16 @@ const $textarea = $container.find( 'textarea' ); // Prevent potential id overlap by appending the textarea field with a random id. - let id = $textarea.data( 'tinymce-id' ); + let id = $textarea.attr( 'data-tinymce-id' ) || $textarea.data( 'tinymce-id' ); if ( ! id ) { id = $textarea.attr( 'id' ) + Math.floor( Math.random() * 1000 ); - $textarea.data( 'tinymce-id', id ); - $textarea.attr( 'id', id ); } + $textarea + .data( 'tinymce-id', id ) + .attr( 'data-tinymce-id', id ) + .attr( 'id', id ); + $( window.document ).one( 'wp-before-tinymce-init', function( event, init ) { if ( init.selector !== settings.tinymce.selector ) { return; @@ -163,7 +301,7 @@ if ( $wpautopToggleField ) { $wpautopToggleField.off( 'change' ); $wpautopToggleField.on( 'change', function() { - window.wp.editor.remove( id ); + removeTinyMCEEditor( window.wp.editor, id ); settings.tinymce.wpautop = $wpautopToggleField.is( ':checked' ); window.wp.editor.initialize( id, settings ); } ); @@ -188,7 +326,7 @@ } ); } - wpEditor.remove( id ); + removeTinyMCEEditor( wpEditor, id ); if ( window.tinymce ) { window.tinymce.EditorManager.overrideDefaults( { base_url: settings.baseURL, suffix: settings.suffix } ); } @@ -201,8 +339,11 @@ if ( $textarea.is( ':visible' ) ) { wpEditor.initialize( id, settings ); clearInterval( intervalId ); + $field.removeData( 'sowb-tinymce-visibility-poll' ); } }, 500 ); + + $field.data( 'sowb-tinymce-visibility-poll', intervalId ); } $field.on( 'click', function( event ) { @@ -247,20 +388,24 @@ const setupTinyMCEFieldInitializer = function() { const $field = $( this ); + if ( $field.attr( 'data-pre-init' ) && ! $field.data( 'sowb-pre-init-bound' ) ) { + $field.removeAttr( 'data-pre-init' ); + } + // If the field is visible, initialize the TinyMCE editor immediately. if ( $field.is( ':visible' ) ) { setupTinyMCEField( $field ); return; } - // If the field is already marked for initialization, skip further setup. - else if ( $field.attr( 'data-pre-init' ) ) { + + if ( $field.data( 'sowb-pre-init-bound' ) ) { return; } // Mark the field for initialization and wait for it to become visible. // Once visible, the 'sowsetupformfield' event triggers the editor setup. $field - .attr( 'data-pre-init', true ) + .data( 'sowb-pre-init-bound', true ) .one( 'sowsetupformfield', () => { setupTinyMCEField( $field ); } ); @@ -283,9 +428,53 @@ } $form.find( '.siteorigin-widget-field-type-tinymce' ).each( function() { - $( this ).removeAttr( 'data-initialized' ); - setupTinyMCEField( $( this ) ); + const $field = $( this ); + clearTinyMCEFieldPendingSetup( $field ); + $field.removeAttr( 'data-initialized' ); + setupTinyMCEField( $field ); + } ); + }; + + /** + * Initializes TinyMCE fields inside the Site Editor canvas iframe. + * + * The parent editor can post this request before iframe field scripts have + * finished loading, so the iframe also calls this once its own script is + * ready. + */ + const setupSiteEditorTinyMCEFields = function() { + if ( + window.wp && + window.wp.editor && + ! window.wp.editor.getDefaultSettings && + window.top.wp && + window.top.wp.editor + ) { + window.wp.editor.getDefaultSettings = window.top.wp.editor.getDefaultSettings; + } + + $( '.siteorigin-widget-field-type-tinymce' ).each( function() { + setupTinyMCEFieldInitializer.call( this ); } ); + + // Check if the sortstop event is already bound. + if ( ! $( window.top.document ).data( 'sortstop-bound' ) ) { + $( window.top.document ).data( 'sortstop-bound', true ); + $( window.top.document ).on( 'sortstop', sortStopEvent ); + } + }; + + let siteEditorSetupScheduled = false; + const scheduleSiteEditorTinyMCEFields = function() { + if ( siteEditorSetupScheduled ) { + return; + } + + siteEditorSetupScheduled = true; + setTimeout( function() { + siteEditorSetupScheduled = false; + setupSiteEditorTinyMCEFields(); + }, 50 ); }; @@ -305,20 +494,39 @@ // Add support for the Site Editor. window.addEventListener( 'message', function( e ) { if ( e.data && e.data.action === 'sowbBlockFormInit' ) { - $( '.siteorigin-widget-field-type-tinymce' ).each( function() { - setupTinyMCEFieldInitializer.call( this ); - } ); + setupSiteEditorTinyMCEFields(); + } + } ); - if ( ! window.wp.editor.getDefaultSettings ) { - window.wp.editor.getDefaultSettings = window.top.wp.editor.getDefaultSettings; - } + if ( window.frameElement ) { + $( document ).on( 'sowsetupformfield', '.siteorigin-widget-field-type-tinymce', setupTinyMCEFieldInitializer ); + $( setupSiteEditorTinyMCEFields ); - // Check if the sortstop event is already bound. - if ( ! $( window.top.document ).data( 'sortstop-bound' ) ) { - $( window.top.document ).data( 'sortstop-bound', true ); - $( window.top.document ).on( 'sortstop', sortStopEvent ); - } + if ( window.MutationObserver ) { + $( function() { + if ( ! document.body ) { + return; + } + + const observer = new MutationObserver( scheduleSiteEditorTinyMCEFields ); + observer.observe( document.body, { + attributes: true, + attributeFilter: [ 'class', 'style' ], + childList: true, + subtree: true, + } ); + } ); + } else { + let siteEditorSetupAttempts = 0; + const siteEditorSetupInterval = setInterval( function() { + setupSiteEditorTinyMCEFields(); + siteEditorSetupAttempts++; + + if ( siteEditorSetupAttempts >= 20 ) { + clearInterval( siteEditorSetupInterval ); + } + }, 250 ); } - } ); + } } )( jQuery ); diff --git a/base/js/admin.js b/base/js/admin.js index a935e85b4..345a76186 100644 --- a/base/js/admin.js +++ b/base/js/admin.js @@ -42,6 +42,20 @@ var sowbForms = window.sowbForms || {}; } } + const repeaterVisibilitySensitiveFieldSelector = '.siteorigin-widget-field-type-tinymce, .siteorigin-widget-field-type-date-range'; + + const triggerVisibleRepeaterVisibilityFieldSetup = ( $container ) => { + $container + .filter( repeaterVisibilitySensitiveFieldSelector ) + .add( $container.find( repeaterVisibilitySensitiveFieldSelector ) ) + .filter( function() { + return $( this ).is( ':visible' ); + } ) + .each( function() { + $( this ).trigger( 'sowsetupformfield' ); + } ); + } + $.fn.sowSetupForm = function () { return $(this).each(function (i, el) { @@ -907,6 +921,7 @@ var sowbForms = window.sowbForms || {}; $el.find( '> .siteorigin-widget-field-repeater-items' ).append( item ).sortable( 'refresh' ).trigger( 'updateFieldPositions' ); item.sowSetupRepeaterItems(); item.hide().slideDown( 'fast', function () { + triggerVisibleRepeaterVisibilityFieldSetup( $( this ) ); $( window ).trigger( 'resize' ); }); $el.trigger( 'change' ); @@ -1099,31 +1114,16 @@ var sowbForms = window.sowbForms || {}; e.preventDefault(); $itemForm.slideToggle( { duration: 'fast', - start: function() { + complete: function() { const $this = $( this ); if ( initialState === 'open' ) { $this.trigger( 'slideToggleCloseComplete' ); - return; + } else { + $this.trigger( 'slideToggleOpenComplete' ); + triggerVisibleRepeaterVisibilityFieldSetup( $this ); } - $this.trigger( 'slideToggleOpenComplete' ); - - const $fields = $this.find( - '.siteorigin-widget-field-type-section > .siteorigin-widget-section > .siteorigin-widget-field, .siteorigin-widget-field' - ); - - const visibleFields = $fields.filter( function() { - return $( this ).is( ':visible' ); - } ); - - // Trigger 'sowsetupformfield' for all visible fields in a single batch - visibleFields.each( function() { - $( this ).trigger( 'sowsetupformfield' ); - } ); - - }, - complete: () => { $( window ).trigger( 'resize' ); } } ); @@ -1260,6 +1260,7 @@ var sowbForms = window.sowbForms || {}; // Prevent potential id overlap by appending the textarea field with a random id. newId += Math.floor( Math.random() * 1000 ); $inputElement.data( 'tinymce-id', newId ); + $inputElement.attr( 'data-tinymce-id', newId ); } $inputElement.attr( 'id', newId ); @@ -1296,6 +1297,7 @@ var sowbForms = window.sowbForms || {}; $items.append( $copyItem ).sortable( 'refresh' ).trigger( 'updateFieldPositions' ); $copyItem.sowSetupRepeaterItems(); $copyItem.hide().slideDown( 'fast', function () { + triggerVisibleRepeaterVisibilityFieldSetup( $( this ) ); $( window ).trigger( 'resize' ); }); // If increment is enabled for this item, trigger label updates. @@ -1312,7 +1314,8 @@ var sowbForms = window.sowbForms || {}; } }); - $el.find( '> .siteorigin-widget-field-repeater-item-form' ).sowSetupForm(); + const $itemForm = $el.find( '> .siteorigin-widget-field-repeater-item-form' ); + $itemForm.sowSetupForm(); $el.data( 'sowrepeater-actions-setup', true ); } diff --git a/compat/block-editor/widget-block.js b/compat/block-editor/widget-block.js index 1c564ada3..7574b75b7 100644 --- a/compat/block-editor/widget-block.js +++ b/compat/block-editor/widget-block.js @@ -244,9 +244,10 @@ * @param {Object} props - The properties passed to the function. * @param {Object} state - The current state of the component. * @param {Function} setState - The setState function to update the component's state. + * @param {Object} activeRequestRef - React ref tracking the active preview request. */ - const sowbSetupWidgetForm = async ( props, state, setState ) => { + const sowbSetupWidgetForm = async ( props, state, setState, activeRequestRef ) => { const $mainForm = sowbGetBlockForm( props.clientId ); if ( $mainForm.length === 0 || state.formInitialized ) { @@ -319,21 +320,72 @@ * display blocks. This requires us to reinitialize WB form fields * in the iframe context after the form has been set up. */ - const initializeFormFieldsInIframe = () => { - if ( ! sowbSiteEditorCanvas || sowbSiteEditorCanvas.length === 0 ) { - return; + const initializeFormFieldsInIframe = ( clientId ) => { + const iframeElement = sowbResolveSiteEditorFrame(); + + if ( ! iframeElement ) { + return null; } + let iframeWindow; + let targetOrigin = window.location && window.location.origin ? + window.location.origin : + '*'; + try { - const iframeWindow = sowbSiteEditorCanvas[0].contentWindow; - if ( iframeWindow ) { - iframeWindow.postMessage( { - action: 'sowbBlockFormInit' - }, '*' ); + iframeWindow = iframeElement.contentWindow; + + const iframeDocument = iframeElement.contentDocument || ( + iframeWindow && + iframeWindow.document + ); + + if ( + iframeDocument && + iframeDocument.location && + iframeDocument.location.origin && + iframeDocument.location.origin !== 'null' + ) { + targetOrigin = iframeDocument.location.origin; } } catch ( e ) { - console.error( 'SiteOrigin Widgets: Failed to send postMessage to iframe:', e ); + // Keep the parent origin fallback when same-origin document access + // is unavailable but the iframe window reference is still usable. + } + + if ( ! iframeWindow ) { + return null; } + + const timeoutIds = []; + const blockSelector = clientId ? `[data-block="${ clientId }"]` : ''; + const formSelector = blockSelector ? + `${ blockSelector } .siteorigin-widget-form-main` : + ''; + const message = { + action: 'sowbBlockFormInit', + clientId, + blockSelector, + formSelector + }; + + const sendInitMessage = () => { + try { + iframeWindow.postMessage( message, targetOrigin ); + } catch ( e ) { + console.error( 'SiteOrigin Widgets: Failed to send postMessage to iframe:', e ); + } + }; + + [ 0, 250, 1000, 3000, 6000 ].forEach( ( delay ) => { + timeoutIds.push( setTimeout( sendInitMessage, delay ) ); + } ); + + return () => { + timeoutIds.forEach( ( timeoutId ) => { + clearTimeout( timeoutId ); + } ); + }; }; /** @@ -378,6 +430,7 @@ const isMountedRef = element.useRef( true ); const activeRequestRef = element.useRef( null ); + const iframeFormInitKeyRef = element.useRef( null ); // Ensure widgetClass attribute is set once (was done in constructor). element.useEffect( () => { @@ -482,6 +535,34 @@ // or when the editing flag flips. Using props.attributes.widgetData and state.editing. }, [ props.attributes.widgetData, state.editing ] ); + element.useEffect( () => { + if ( ! state.editing || ! state.widgetFormHtml || ! state.formInitialized ) { + iframeFormInitKeyRef.current = null; + return; + } + + const initKey = `${ props.clientId }::${ state.widgetFormHtml }`; + if ( iframeFormInitKeyRef.current === initKey ) { + return; + } + + // In dev mode, blocks are rendered twice in quick succession. Wait + // for the remount pass before sending iframe field initialization. + if ( ! sowbBlockEditorAdmin.wpScriptDebug || state.devModeRemount ) { + const cleanup = initializeFormFieldsInIframe( props.clientId ); + if ( cleanup ) { + iframeFormInitKeyRef.current = initKey; + return cleanup; + } + } + }, [ + props.clientId, + state.editing, + state.widgetFormHtml, + state.formInitialized, + state.devModeRemount + ] ); + // Use block props hook to provide wrapper attributes/classes for API v3. const blockProps = blockEditor && blockEditor.useBlockProps ? blockEditor.useBlockProps() : {}; @@ -541,16 +622,9 @@ sowbSetupWidgetForm( props, state, - mergeState - ).then( () => { - // In dev mode, blocks are rendered twice in - // quick succession. To prevent issues with - // form field scripts we need to wait until the - // second render to initialize the fields. - if ( ! sowbBlockEditorAdmin.wpScriptDebug || state.devModeRemount ) { - initializeFormFieldsInIframe(); - } - } ); + mergeState, + activeRequestRef + ); } } ) ) @@ -815,9 +889,6 @@ } ) ); - // Register all of our manually registered blocks. - await soRegisterWidgetBlocks( sowbManuallyRegisteredBlocks ); - // Modify all of the manually registered blocks with additional properties. Object.entries( sowbManuallyRegisteredBlocks ).forEach( ( [ key, widget ] ) => { if ( ! widget.blockName ) { @@ -849,6 +920,10 @@ ); } ); + // Register all of our manually registered blocks after the filters above are + // in place so they receive the same edit component as regular widget blocks. + await soRegisterWidgetBlocks( sowbManuallyRegisteredBlocks ); + /** * Registers a SiteOrigin Widget as a block. * @@ -929,6 +1004,33 @@ let sowbSiteEditorCanvas = false; +const sowbResolveSiteEditorFrame = ( frame = null ) => { + if ( frame ) { + if ( frame.jquery ) { + return frame.length > 0 ? frame[0] : null; + } + + return typeof frame[0] !== 'undefined' ? frame[0] : frame; + } + + if ( window.frameElement ) { + sowbSiteEditorCanvas = window.frameElement; + return window.frameElement; + } + + let $canvas = jQuery( '.edit-site-visual-editor__editor-canvas' ); + if ( $canvas.length === 0 ) { + $canvas = jQuery( 'iframe[name="editor-canvas"]' ); + } + + if ( $canvas.length > 0 ) { + sowbSiteEditorCanvas = $canvas; + return $canvas[0]; + } + + return null; +}; + /** * Gets the widget form inside a specific block in either the main editor, or iframe. * @@ -940,16 +1042,29 @@ let sowbSiteEditorCanvas = false; * @returns {jQuery} jQuery reference to the widget form */ const sowbGetBlockForm = ( clientId ) => { - if ( sowbSiteEditorCanvas === false ) { - sowbSiteEditorCanvas = jQuery( '.edit-site-visual-editor__editor-canvas' ); + // Re-resolve the editor canvas on every call rather than caching to false, + // because the iframe element is mounted asynchronously by the block editor. + // Site Editor uses `.edit-site-visual-editor__editor-canvas`; the post editor + // (default since WP 6.5) uses `iframe[name="editor-canvas"]`. + let $canvas = jQuery( '.edit-site-visual-editor__editor-canvas' ); + if ( $canvas.length === 0 ) { + $canvas = jQuery( 'iframe[name="editor-canvas"]' ); + } + + // Cache the resolved canvas for use by sowbMaybeSetupSiteEditorAssets() + // and other consumers, but only when the canvas is actually present so + // we never lock in an empty result before the iframe has mounted. + if ( $canvas.length > 0 ) { + sowbSiteEditorCanvas = $canvas; } - if ( sowbSiteEditorCanvas.length === 0 ) { + if ( $canvas.length === 0 ) { + // No iframe (e.g. widgets.php Block Widgets screen, classic editor). return jQuery( '[data-block="' + clientId + '"]' ).find( '.siteorigin-widget-form-main' ); } - // Return the main WB form. - return sowbSiteEditorCanvas + // Return the main WB form from inside the editor iframe. + return $canvas .contents() .find( '[data-block="' + clientId + '"]' ) .find( '.siteorigin-widget-form-main' ); @@ -961,6 +1076,11 @@ const sowbCanvasCloneElements = [ '#jquery-migrate-js', '#editor-js-after', '#wp-tinymce-js', + '#utils-js', + '#editor-js-extra', + '#editor-js', + '#quicktags-js-extra', + '#quicktags-js', '#wp-block-library-js-before', '#jquery-ui-core-js', '#jquery-ui-mouse-js', @@ -968,6 +1088,9 @@ const sowbCanvasCloneElements = [ '#jquery-ui-sortable-js', '#jquery-ui-resizable-js', '#jquery-ui-draggable-js', + '#jquery-touch-punch-js', + '#iris-js', + '#wp-color-picker-js', '#wplink-js-extra', '#wplink-js', '#buttons-css', @@ -1027,31 +1150,317 @@ const sowbCanvasCloneElements = [ '#so-toggle-field-js', ]; +const sowbNormalizeAssetUrl = ( value, baseHref ) => { + if ( ! value ) { + return ''; + } + + try { + return new URL( + value, + baseHref || window.location.href + ).href; + } catch ( e ) { + return ''; + } +}; + +const sowbGetDocumentHref = ( doc ) => { + return doc && doc.location && doc.location.href ? + doc.location.href : + window.location.href; +}; + +const sowbGetElementById = ( doc, id ) => { + if ( ! doc || ! id || typeof doc.getElementById !== 'function' ) { + return null; + } + + return doc.getElementById( id ); +}; + +/** + * Gets the editor settings for a TinyMCE container. + * + * @param {Element} element TinyMCE container element. + * + * @returns {Object} Parsed editor settings. + */ +const sowbGetEditorSettingsFromContainer = ( element ) => { + const $element = jQuery( element ); + const editorSettings = $element.data( 'editorSettings' ); + + if ( editorSettings && typeof editorSettings === 'object' ) { + return editorSettings; + } + + const rawSettings = $element.attr( 'data-editor-settings' ); + if ( ! rawSettings ) { + return {}; + } + + try { + const parsedSettings = JSON.parse( rawSettings ); + return parsedSettings && typeof parsedSettings === 'object' ? + parsedSettings : + {}; + } catch ( e ) { + return {}; + } +}; + +/** + * Derives the asset root for a TinyMCE external plugin URL. + * + * @param {string} pluginUrl External plugin URL. + * + * @returns {string} Normalized asset root URL. + */ +const sowbGetTinyMCEExternalPluginAssetRoot = ( pluginUrl ) => { + const normalizedPluginUrl = sowbNormalizeAssetUrl( + pluginUrl, + window.location.href + ); + + if ( ! normalizedPluginUrl ) { + return ''; + } + + const assetRoot = new URL( normalizedPluginUrl ); + assetRoot.search = ''; + assetRoot.hash = ''; + + const jsPathIndex = assetRoot.pathname.indexOf( '/js/' ); + if ( jsPathIndex !== -1 ) { + assetRoot.pathname = assetRoot.pathname.substring( 0, jsPathIndex + 4 ); + return assetRoot.href; + } + + const lastSlashIndex = assetRoot.pathname.lastIndexOf( '/' ); + assetRoot.pathname = assetRoot.pathname.substring( 0, lastSlashIndex + 1 ); + + return assetRoot.href; +}; + +/** + * Gets TinyMCE external plugin asset roots from mounted iframe widget forms. + * + * @param {jQuery} $canvasBody The iframe body. + * + * @returns {Set} Set of normalized asset root URLs. + */ +const sowbGetTinyMCEExternalPluginAssetRoots = ( $canvasBody ) => { + const assetRoots = new Set(); + + $canvasBody.find( '.siteorigin-widget-tinymce-container' ).each( function() { + const settings = sowbGetEditorSettingsFromContainer( this ); + const externalPlugins = settings && + settings.tinymce && + settings.tinymce.external_plugins ? + settings.tinymce.external_plugins : + null; + + if ( ! externalPlugins || typeof externalPlugins !== 'object' ) { + return; + } + + Object.values( externalPlugins ).forEach( ( pluginUrl ) => { + if ( typeof pluginUrl !== 'string' || pluginUrl.length === 0 ) { + return; + } + + const assetRoot = sowbGetTinyMCEExternalPluginAssetRoot( pluginUrl ); + if ( assetRoot ) { + assetRoots.add( assetRoot ); + } + } ); + } ); + + return assetRoots; +}; + +/** + * Appends a cloned source element to the canvas when it is not already present. + * + * @param {jQuery} $canvasBody The iframe body. + * @param {Element} element The source element to clone. + * @param {jQuery} $source Source document wrapper. + * + * @returns {boolean} Whether an element was cloned. + */ +const sowbCloneElementToCanvas = ( $canvasBody, element, $source ) => { + if ( ! element || ! element.outerHTML ) { + return false; + } + + const canvasDoc = $canvasBody[0] && $canvasBody[0].ownerDocument; + const sourceDoc = $source && $source[0] && $source[0].nodeType === 9 ? + $source[0] : + element.ownerDocument; + + if ( + element.id && + sowbGetElementById( canvasDoc, element.id ) + ) { + return false; + } + + const assetAttribute = element.hasAttribute( 'src' ) ? 'src' : ( + element.hasAttribute( 'href' ) ? 'href' : '' + ); + + if ( assetAttribute ) { + const assetUrl = sowbNormalizeAssetUrl( + element.getAttribute( assetAttribute ), + sowbGetDocumentHref( sourceDoc ) + ); + + if ( assetUrl ) { + const selector = assetAttribute === 'src' ? 'script[src]' : 'link[href]'; + const existingAsset = $canvasBody.find( selector ).toArray().some( ( candidate ) => { + return sowbNormalizeAssetUrl( + candidate.getAttribute( assetAttribute ), + sowbGetDocumentHref( candidate.ownerDocument ) + ) === assetUrl; + } ); + + if ( existingAsset ) { + return false; + } + } + } + + $canvasBody.append( element.outerHTML ); + return true; +}; + +/** + * Clones the inline localization script related to a source script handle. + * + * @param {jQuery} $canvasBody The iframe body. + * @param {jQuery} $source Source document wrapper. + * @param {string} scriptId Source script element id. + * + * @returns {boolean} Whether an inline script was cloned. + */ +const sowbCloneRelatedInlineScript = ( $canvasBody, $source, scriptId ) => { + if ( ! scriptId ) { + return false; + } + + const inlineScriptId = scriptId + '-extra'; + const sourceDoc = $source && $source[0] && $source[0].nodeType === 9 ? + $source[0] : + document; + const canvasDoc = $canvasBody[0] && $canvasBody[0].ownerDocument; + const inlineScript = sowbGetElementById( sourceDoc, inlineScriptId ); + + if ( + ! inlineScript || + sowbGetElementById( canvasDoc, inlineScriptId ) + ) { + return false; + } + + return sowbCloneElementToCanvas( $canvasBody, inlineScript, $source ); +}; + /** * Appends elements to the canvas body. * - * @param {jQuery} $canvasBody The jQuery object representing the canvas body.* + * @param {jQuery} $canvasBody The jQuery object representing the canvas body. + * @param {Document} [sourceDoc] The document to read source nodes from. When + * this helper runs from inside the editor iframe, + * the source `