2

I created a custom Gutenberg block using plain JavaScript. That all works well (no build-step yay!). There is nothing done in PHP, it is JavaScript all the way -- aside from the single register_block_type_from_metadata() declaring my custom block.

This is a snippet of my block.json:

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "hvt/tabbed-block",
    // ...
    "editorScript": "file:./hvt.editor.js",
    "script": "file:./hvt.js"
}

My custom block needs some (again plain) JavaScript initializing code in order to work. This initialization code is needed in both the editor and in the regular ('frontend') site. Therefore this code is inside hvt.js. This works fine in the 'frontend', and also in the editor whenever I open a post or page that already contains my custom block.

In short, my initialization code runs after DOMContentLoaded is triggered. The most important thing it does, is to attach some click listeners. The initialization code is called from the content loaded listener like this:

var HVT = {
    initializeTabbedBlock: (tabbedBlock) => {
        // Here some click listeners are added to descendants of tabbedBlock
    }
};

document.addEventListener(
    'DOMContentLoaded',
    () => {
        const tabbedBlocks = document.querySelectorAll('div.tabbed-block-tabs');
        if (0 === tabbedBlocks.length) {
            return;
        }

        for (const tabbedBlock of tabbedBlocks) {
            HVT.initializeTabbedBlock(tabbedBlock);
        }
    }
);

Now in the editor, whenever my custom block is inserted on a post or page, I want to run the initialization code (i.e. HVT.initializeTabbedBlock) as well.

A small snippet of hvt.editor.js:

(function(blocks, blockEditor, components, data, element) {
    blocks.registerBlockType(
        'hvt/tabbed-block',
        {
            title: 'HVT tabbed block',
            edit: () => {
                const blockProps = useBlockProps({
                    className: 'hvt-tabbed-block',
                });

                // ...
            },
            save: () => {
                const blockProps = useBlockProps.save({
                    className: 'hvt-tabbed-block',
                });

                // ...
            },
        }
    );
})(window.wp.blocks, window.wp.blockEditor, window.wp.components, window.wp.data, window.wp.element);

So is there some hook I can attach to, whenever my custom block is inserted / appended in the visual editor?

(I do have tried working around me not finding a great hook to attach to, by adding a MutationObserver on document.body in the editor, and then trying to see if my custom block is added. That does however not work, because of the iframe that Gutenberg inserts. It seems that I do not receive any mutation events of the actual edited content, I only receive mutation events from the editor sidebars... Although I do think there is a much simpler non-shotgun way of achieving what I want.)

EDIT I've updated my question a bit to iterate that I am solely using block.json to declare my custom block.

1
  • Hopefully someone has a better answer, but in my experience its not usually appropriate to initialise any front-end interactivity in the editor if it's manipulating the DOM with anything other than React. When it comes to tabs, for example, I've always had a tab script that loads on the front end, but in the editor I implement a more rudimentary version to handle tab navigation in the editor, and that's only if that navigation is required for editing the block contents. If it's not then I wouldn't support interactivity in the editor at all. Commented Jan 10 at 10:58

3 Answers 3

2
+50

So is there some hook I can attach to, whenever my custom block is inserted / appended in the visual editor?

At the time of writing, none, as far as I know.

But there is a function, namely wp.data.subscribe(), which we can use to listen to (state) changes in the editor, e.g. when the post is published, when the title or permalink/slug is changed, when a new block is added to or removed from the post, etc.

Therefore, you could use that function to run HVT.initializeTabbedBlock() whenever a new block of your block type is added to the post. Here's an example which uses _.difference() (which means underscore needs to be added to your script's dependencies) and an IIFE:

( ( blockName ) => {
    const { subscribe, select } = wp.data;

    // Used for storing the current target blocks.
    let _previousBlockClientIds = [];

    subscribe( () => {
        const currentBlocks        = select( 'core/block-editor' ).getBlocks();
        const targetBlockClientIds = [];

        // A recursive function which searches for the clientId of all the target
        // blocks, including inner blocks.
        const findTargetBlocks = ( blocks ) => {
            blocks.forEach( block => {
                if ( block.innerBlocks.length ) {
                    findTargetBlocks( block.innerBlocks );
                } else if ( blockName === block.name ) {
                    targetBlockClientIds.push( block.clientId );
                }
            } );
        };

        findTargetBlocks( currentBlocks );
        const addedBlockClientId = _.difference( targetBlockClientIds, _previousBlockClientIds )?.[0];

        if ( addedBlockClientId ) {
            console.log( `New ${ blockName } added. Current total: ${ targetBlockClientIds.length }` );

            // A new block was added, but we should wait until the block node is attached to the DOM.
            const unsubscribe = subscribe( () => {
                const tabbedBlock = document.querySelector( `[data-block="${ addedBlockClientId }"]` );

                if ( tabbedBlock ) {
                    HVT.initializeTabbedBlock( tabbedBlock );
                    unsubscribe();
                }
            } );
        }

        _previousBlockClientIds = targetBlockClientIds;
    } );
} )( 'hvt/tabbed-block' );

However, if it's really just about attaching a click listener or another event handler, then I would just attach it directly on the corresponding elements. E.g.

Note: The HTML markup was based on the Gist here. I also used JSX because the code would be simpler, i.e. easier to read.

// In the `edit` function:

return (
    <div { ...blockProps }>
        <div className="hvt-tabbed-block-tabs">
            <a className="hvt-tabbed-block-tab" onClick={ HVT.onTabbedBlockTabSelect }>Tab 1</a>
            <a className="hvt-tabbed-block-tab" onClick={ HVT.onTabbedBlockTabSelect }>Tab 2</a>
            ...
        </div>
        ...
    </div>
);

Or if you don't like that, or that the initialization code is too "long" or not as simple as attaching event handlers, then you can use a ref with useEffect, like so:

// In the `edit` function:

// Create a ref for .hvt-tabbed-block-tabs.
const blockTabsRef = useRef( null );

// Run the initialization code via useEffect.
useEffect( () => {
    HVT.initializeTabbedBlock( blockTabsRef.current );
}, [] );

return (
    <div { ...blockProps }>
        <div className="hvt-tabbed-block-tabs" ref={ blockTabsRef }>
            <a className="hvt-tabbed-block-tab">Tab 1</a>
            <a className="hvt-tabbed-block-tab">Tab 2</a>
            ...
        </div>
        ...
    </div>
);

What I would really do

After looking at your Gist, I thought that I'd probably just "copy" the same behaviors of the HVT object into the block's edit function.

Here's an example which uses a local state named activeTab to control the tabs and panes, e.g. to add/remove the active tab class which then activates the pane for the clicked tab:

// In the `edit` function:

const blockProps = useBlockProps( {
    className: 'hvt-tabbed-block',
} );

// This is otherwise `attributes.tabs`, and the `id` property is just an example
// of setting a unique tab identifier.
const tabs = [
    { title: 'Tab 1', id: 'tab-1' },
    { title: 'Tab 2', id: 'tab-2' },
    { title: 'Tab 3', id: 'tab-3' },
];

// Create a state for the active tab, with the first tab in the tabs array being
// the default active tab.
const [ activeTab, setActiveTab ] = useState( tabs?.[0]?.id );

function onTabClick( tabId ) {
    setActiveTab( tabId );

    return false; // Equivalent or alternative to calling event.preventDefault().
}

return (
    <div { ...blockProps }>
        <div className="hvt-tabbed-block-tabs">
            {
                tabs.map( ( tab, i ) => (
                    <a
                        className={ 'hvt-tabbed-block-tab' + // wrapped for brevity
                            ( activeTab === tab.id ? ' hvt-tabbed-block-tab-active' : '' ) }
                        data-hvt-tabbed-block-id={ tab.id }
                        onClick={ () => onTabClick( tab.id ) }
                        key={ 'tab_' + ( tab.id || i ) }
                    >
                        { tab.title }
                    </a>
                ) )
            }
        </div>

        <div className="hvt-tabbed-block-panes">
            {
                tabs.map( ( tab, i ) => (
                    <div
                        className="hvt-tabbed-block-pane"
                        data-hvt-tabbed-block-pane-target={ tab.id }
                        style={ { display: ( tab.id === activeTab ? 'block' : 'none' ) } }
                        key={ 'pane_' + ( tab.id || i ) }
                    >
                        Pane content for <b>{ tab.title }</b>
                    </div>
                ) )
            }
        </div>
    </div>
);

So, it's not actually that much of work to make one for Gutenberg, and particularly when you also use JSX, the code becomes easier to read. 🙂

5
  • Hey Sally, interesting approaches. Your 1st solution is a lot like what I tried, using a MutationObserver, not really a fan of that. The second solution would work, but I am trying to re-use the frontend init, to avoid double work. The last approach comes close. The useEffect does indeed try to initiate my custom block. However, my block has two parts, one are the 'tabs' itself, the second part are the 'panes'. The init code inits both, only the panes are not yet drawn in the DOM when the useEffect is executed, I think because the panes are InnerBlocks. Any idea how to fix this?
    – hvtilborg
    Commented Jan 22 at 18:49
  • I would not actually go with the subscribe option, so useEffect should work for you. But since it didn't really work, then can we see your full edit function, maybe on GitHub? I supposed you've got 2 blocks - a parent block for the tabs and a child block for the panes?
    – Sally CJ
    Commented Jan 23 at 9:33
  • Hey Sally, I've add a gist of what I'm doing as a GitHub GIst: gist.github.com/hvt/bdacc05437c0179edbbf95180c85fa80. It truly is a gist, but I annotated what goes wrong in the comments.
    – hvtilborg
    Commented Jan 23 at 19:31
  • As for your comment here, one of the reasons is because your edit function used hvt, but HVT.onTabbedBlockTabSelect used dnv, e.g. the class names hvt-tabbed-block vs dnv-tabbed-block. So you need to ensure your edit function returns the correct HTML markup as expected by the click handler.
    – Sally CJ
    Commented Jan 24 at 16:59
  • Please ignore my previous comment (which I've deleted), but if the revised answer didn't help or that you'd rather use ref + useEffect, please add to your gist the edit function for the hvt/tabbed-block-tab block.
    – Sally CJ
    Commented Jan 25 at 5:59
-1

If you want to add the JavaScript on the front end you could try the render_block filter. You would not modify the block content but append the JavaScript or wp_enqueue_script() the code if it is in a separate file.

(I cannot remember what $block parameter has - you probably want to ensure it is your custom block)

add_filter( 'render_block', 'add_hvt_init', 10, 2 );
function add_hvt_init( $block_content, $block ) {
    // Load JS in footer.
    wp_enqueue_script( 'hvt_init', 'hvt_init.js', null, null, array( 'in_footer' => false,  ) );

    // or append it to the block markup
    $hvt_js = 'document.addEventListener( .... );';
    return $block_content . $hvt_js;
}

It might be more efficient to use the the render_callback parameter for register_block_type().

2
  • 1
    They were specifically asking about doing it in the block editor. Commented Jan 10 at 4:00
  • 1
    Correct @JacobPeattie , although I will update my question to iterate that.
    – hvtilborg
    Commented Jan 10 at 6:36
-1

For testing I would do something like this, then once you get it working move it to a .js file as described above. in your functions.php file add this code, then you can use it when you want:

function wpb_add_inline_footer_script() {
    ?>
    <script type="text/javascript">
        document.addEventListener('DOMContentLoaded', () => {
            const tabbedBlocks = document.querySelectorAll('div.tabbed-block-tabs');
            if (0 === tabbedBlocks.length) {
                return;
            }

            for (const tabbedBlock of tabbedBlocks) {
                HVT.initializeTabbedBlock(tabbedBlock);
            }
        });
    </script>
    <?php
}
add_action('wp_footer', 'wpb_add_inline_footer_script');

If you want to display it as a shortcode you can do it like this, then have a custom version for each time you want to use it by creating a new shortcode with different number like this: Put this in your functions.php file:

function wpb_display_js_234_shortcode() {
    ob_start(); // Start output buffering
    ?>
    <script type="text/javascript">
        document.addEventListener('DOMContentLoaded', () => {
            const tabbedBlocks = document.querySelectorAll('div.tabbed-block-tabs');
            if (0 === tabbedBlocks.length) {
                return;
            }

            for (const tabbedBlock of tabbedBlocks) {
                HVT.initializeTabbedBlock(tabbedBlock);
            }
        });
    </script>
    <?php
    return ob_get_clean(); // Return the buffered output
}
add_shortcode('wpb_display_js_234', 'wpb_display_js_234_shortcode');

Then in your editor put this:

[wpb_display_js_234]

It will trigger the above javascript code to display in the footer.

=========== Here is how you can put the javascript in an external file like it is supposed to be:

To put your JavaScript code in an external file and then execute it using a shortcode in WordPress, you need to follow these steps:

Create the JavaScript File:

Create a new JavaScript file, for example, my-custom-script.js. Place your JavaScript code inside this file:

document.addEventListener('DOMContentLoaded', () => {
    const tabbedBlocks = document.querySelectorAll('div.tabbed-block-tabs');
    if (0 === tabbedBlocks.length) {
        return;
    }

    for (const tabbedBlock of tabbedBlocks) {
        HVT.initializeTabbedBlock(tabbedBlock);
    }
});

Then put this in your theme's functions.php file

function wpb_enqueue_my_custom_script() {
    wp_enqueue_script('wpb-my-custom-script', get_template_directory_uri() . '/js/my-custom-script.js', array(), false, true);
}
add_action('wp_enqueue_scripts', 'wpb_enqueue_my_custom_script');

Create a Shortcode to Load the Script:

In the same functions.php file, create a shortcode function that will add a script loader to the page:

function wpb_display_js_234_shortcode() {
    // Check if the script is already enqueued to avoid duplicates
    if (!wp_script_is('wpb-my-custom-script', 'enqueued')) {
        wp_enqueue_script('wpb-my-custom-script');
    }
    return '';
}
add_shortcode('wpb_display_js_234', 'wpb_display_js_234_shortcode');

Use the Shortcode:

Now you can use the shortcode [wpb_display_js_234] in your posts or pages. This shortcode will ensure that your external JavaScript file is loaded on the page where it's used.

Keep in mind that caching make make your code not show if it is in the external file, for development I usually add a version number and then increase it when I make changes to the file like this:

function wpb_enqueue_my_custom_script() {
    wp_enqueue_script(
        'wpb-my-custom-script', 
        get_template_directory_uri() . '/js/my-custom-script.js', 
        array(), 
        rand(), // Add rand() here for cache busting
        true
    );
}
add_action('wp_enqueue_scripts', 'wpb_enqueue_my_custom_script');

In this code, the rand() function generates a random number each time the page is loaded, which is appended to the script URL as a query string. This prevents the browser from using a cached version of the script, ensuring that the latest version is always loaded.

Remember, while this approach is useful during development, for a production environment, it's generally better to use version numbers that change only when the script changes. This allows browsers to cache the script effectively, improving load times for returning visitors.

3
  • They were specifically asking about doing it in the block editor. Commented Jan 10 at 3:59
  • You can't put js code in the code editor, but you can make it display in the code editor like I described above. Commented Jan 10 at 17:24
  • That’s not what the question was though. Your answer has nothing to do with the original question. Commented Jan 11 at 3:22

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.