Gutenberg: body class / elements by template

It makes sense for themes to style the Gutenberg editor, too, for body styling and background (<img>).

Note: Thanks to GitHub user websevendev for the code this has been derived from: **

resources/assets/scripts/lib/PageTemplateWatcher/index.js:

import '@webcomponents/template'; // <template> polyfill (IEs / Opera Mini)


const defaultTemplateClass             = 'page-template-default',
      gutenbergEditorContainerSelector = '.editor-writing-flow > div > div > div > .editor-block-list__layout';


// @see app/helpers.php: filter_templates($templates)
const sageTemplateClassName = function(template) {
  return 'page-template-' +
         template
          .replace('.blade.php', '')
          .replace('.php', '')
          .replace('views/template-', 'template-');
};


// @see https://github.com/WordPress/gutenberg/issues/8162#issuecomment-469233461

/*eslint-disable */
class PageTemplateUpdater extends React.Component {

	componentDidUpdate(prevProps) {
    if(prevProps.template) {
      const className = sageTemplateClassName(prevProps.template);
      document.body.classList.remove(className);
      this._removeTemplateChild(className);
    } else {
      document.body.classList.remove(defaultTemplateClass);
      this._removeTemplateChild(defaultTemplateClass);
    }
	}

	render() {
    this._editorContainer = document.querySelector(gutenbergEditorContainerSelector);

    if(this.props.template) {
      const className = sageTemplateClassName(this.props.template);
      document.body.classList.add(className);
      this._addTemplateChild(className);
    } else {
      document.body.classList.add(defaultTemplateClass);
      this._addTemplateChild(defaultTemplateClass);
    }

    return null;
	}


  _isHomePage() {
    return document.body.classList.contains('home');
  }
  _isBlogPage() {
    return document.body.classList.contains('blog');
  }

  _pageSelector(templateClass, prefix) {
    const isHomePage = this._isHomePage(),
          isBlogPage = this._isBlogPage();

    if(!prefix)
      prefix = '';

    var templateSelector = `${prefix}.${templateClass}`;
    if(isHomePage || isBlogPage)
      templateSelector = templateSelector + ', ' + templateSelector;
    if(isHomePage) {
      templateSelector = templateSelector + '.home';
    } else {
      templateSelector = templateSelector + ':not(.home)';
    }
    if(isBlogPage) {
      templateSelector = templateSelector + '.blog';
    } else {
      templateSelector = templateSelector + ':not(.blog)';
    }

    return templateSelector;
  }

  // <template>
  _addTemplateChild(templateClass) {
    if (!('content' in document.createElement('template'))) return;
      // <template> support available (natively or with polyfill)

    const templateSelector = this._pageSelector(templateClass, 'template');
    const template = document.querySelector(templateSelector);
    if(!template) return;
    const templateFragment = document.importNode(template.content, true);
    if(!templateFragment) return;

    // Wrap template content into element for possible later removal
    const addedTemplate = document.createElement('div');
    addedTemplate.appendChild(templateFragment);
    addedTemplate.classList.add('added-template', templateClass);
    addedTemplate.classList.toggle('home', this._isHomePage());
    addedTemplate.classList.toggle('blog', this._isBlogPage());

    this._editorContainer.appendChild(addedTemplate);
  }

  _removeTemplateChild(templateClass) {
    const templateSelector = this._pageSelector(templateClass, '.added-template');
    const addedTemplate = this._editorContainer.querySelector(templateSelector);
    if(!addedTemplate) return;

    this._editorContainer.removeChild(addedTemplate);
  }
}


const withPageTemplate = wp.compose.createHigherOrderComponent(
	wp.data.withSelect(
		select => {
			const {
				getEditedPostAttribute,
			} = select('core/editor');

			return {
				template: getEditedPostAttribute('template'),
			};
		}
	),
	'withPageTemplate'
);

export default withPageTemplate(PageTemplateUpdater);
/*eslint-enable */

Gutenberg editor script file (or where you put the Gutenberg editor specific code):
resources/assets/scripts/editor.js:

import ReactDOM from 'react-dom';

import PageTemplateWatcher from './lib/PageTemplateWatcher';


// Template body classes and elements from <template> (dynamic)
document.addEventListener('DOMContentLoaded', () => {
	ReactDOM.render(<PageTemplateWatcher />, document.getElementById('my-root'));
});


// Other stuff, like format types and block styles, ...
// ...

Register the Gutenberg editor script and styles (if a separate styles file is used for Gutenberg):
app/setup.php:

/**
 * Gutenberg scripts and styles
 * @see https://www.billerickson.net/block-styles-in-gutenberg/
 */
function gutenberg_scripts() {
    wp_enqueue_script(
        'editor-theme',
        asset_path('scripts/editor.js'),
        array( 'wp-blocks', 'wp-dom' ),
        false,
        true
    );

    wp_enqueue_style(
        'editor-theme',
        asset_path('styles/editor.css'),
        false,
        true
    );
}
add_action( 'enqueue_block_editor_assets', __NAMESPACE__ . '\\gutenberg_scripts' );

resources/assets/config.json:

{
  "entry": {
    [...],
    "editor": [
      "./scripts/editor.js",
      "./styles/editor.scss"
    ]
  },
  [...]
}

$ yarn add @wordpress/eslint-plugin @webcomponents/template
(or)
$ npm install --save @wordpress/eslint-plugin @webcomponents/template
.eslintrc.js:

module.exports = {
  "root": true,
  "extends": [
    "eslint:recommended",
    "plugin:@wordpress/eslint-plugin/react",
    "plugin:@wordpress/eslint-plugin/jsx-a11y"
  ],
  "globals": {
    "wp": true,
    "react-dom": "ReactDOM"
  },
[...]
};

build/webpack.config.js:

[...]
  externals: {
    jquery: 'jQuery',
    'react': 'React',
    'react-dom': 'ReactDOM',
  },
[...]

Add footer element and inject blade template with <template>s to be used in Gutenberg editor page:
app/setup.php:

/*
 * Gutenberg Page Template Watcher markup
 * @see https://github.com/WordPress/gutenberg/issues/8162#issuecomment-469233461
 */
function gutenberg_watcher_markup() {
    ?>
    <div id="my-root"></div>

    <div id="templates">
        <?php echo \App\template('editor/templates'); ?>
    </div>
    <?php
}
add_action( 'admin_footer', __NAMESPACE__ . '\\gutenberg_watcher_markup' );


// Plural (ids), returns all post ids of all translations of a given post id
// (Polylang; WPML)
// @see https://github.com/strarsis/sage9-onepager-lib/blob/master/Helpers.php#L143
function translated_post_ids($post_id) {
    $post_ids = array( $post_id );

    // Polylang support
    if(  function_exists('pll_get_post')  ) {
        $pll_languages = pll_languages_list();
        foreach($pll_languages as $language) {
            $post_translated_id = pll_get_post($post_id, $language);
			if($post_translated_id)
                $post_ids[] = $post_translated_id;
        }
    }

    // WPML support
	if(  function_exists('icl_object_id')  ) {
        $wpml_languages = icl_get_languages('skip_missing=0&orderby=KEY&order=DIR&link_empty_to=str');
        foreach($wpml_languages as $language) {
            $post_translated_id = icl_object_id($post_id, null, false, $language);
            if($post_translated_id)
                $post_ids[] = $post_translated_id;
        }
    }

    return array_unique($post_ids);
}

// Add blog and/or home classes to `<body>` in backend
// Supports translation plugins (Polylang; WPML)
function home_blog_admin_body_class($classes) {
    global $post;
    if(!$post) return;
    $translated_post_ids = translated_post_ids($post->ID);

    $front_page_id = (int) get_option( 'page_on_front' );
    $posts_page_id = (int) get_option( 'page_for_posts' );

    if(in_array($front_page_id, $translated_post_ids))
        $classes .= ' home';

    if(in_array($posts_page_id, $translated_post_ids))
        $classes .= ' blog';

    return $classes;
}
add_filter( 'admin_body_class', __NAMESPACE__ . '\\home_blog_admin_body_class' );

The <template>s to be added in Gutenberg editor page:
views/editor/templates.blade.php:

<template class="page-template-default home">
  @include('partials.backgrounds.outdoor')
</template>

<template class="page-template-template-flowers">
  @include('partials.backgrounds.flowers')
</template>

In Gutenberg editor now the body class is adjusted and the right template content (with the background) is added to page. Also the body class and placed template is swapped when the template is changed by user.

Addendum/errata:

A better config for .eslintrc.js:

module.exports = {
  'root': true,
  'extends': [
    'plugin:@wordpress/eslint-plugin/jsx-a11y',
    'eslint:recommended',
    'plugin:react/recommended',
  ],
  'globals': {
    'wp': true,
    'ReactDOM': true,
  },
  [...]
  'rules': {
    'no-console': 0,
    'quotes': ['error', 'single'],
    'comma-dangle': [
      'error',
      {
        'arrays': 'always-multiline',
        'objects': 'always-multiline',
        'imports': 'always-multiline',
        'exports': 'always-multiline',
        'functions': 'ignore',
      },
    ],
    'react/prop-types': 0,
    'react/react-in-jsx-scope': 0,
  },
};

react-dom doesn’t have to be imported as it is already provided by the Gutenberg editor page:
resources/assets/scripts/editor.js :

// Remove: import ReactDOM from 'react-dom';

import PageTemplateWatcher from './lib/PageTemplateWatcher';


// Template body classes and elements from <template> (dynamic)
document.addEventListener('DOMContentLoaded', () => {
	ReactDOM.render(<PageTemplateWatcher />, document.getElementById('my-root'));
});


// Other stuff, like format types and block styles, ...
// ...

Then in e.g. _global.scss:

$editor-gutenberg-selector: ".editor-writing-flow > div > div > div > .editor-block-list__layout";

For styling a specific page template and its editor area:

// example1
.page-template-template-example1 #{$editor-gutenberg-selector},
body:not(.wp-admin).page-template-template-example1 {
  // styles ...
}