Skip to main content

Adobe

Having Some Fun With The New AEM Layout Editor

In AEM 6.3, the responsive Layout feature was introduced, and the Adobe documentation will show you exactly how it works. Though some customizations and how to achieve them are not super clear, this feature works out of the box. It is certainly a great addition that enables authors to create custom grid layouts that were previously achieved via inflexible column control components.
 

Banner photo by delfi de la Rua on Unsplash

In this post, I will demo a limitation of the layout editor and a way to fix it, purely for experimenting purposes. You might also learn how to build a toolbar action with popovers as a bonus!
 

Pro tip 1: Adobe has JsDoc documentation for the Editor Core javascript API
Pro tip 2: each gif below has an “elapsed time” in the lower right corner to help you know when the gif starts. Is it /dʒɪf/ like “Jeff”or  /ɡɪf/ like “Gift”?!

 

Sample Layout Authoring Experience

Here, I have two components that I want to show side-by-side:

 
Next, I’d like to nest another layout container in the one above:
 

 
I’ve added a new component and authored it to that nested layout container.

Notice how, in the nested layout container, you only get the remaining columns (5) as opposed to what you’d expect in a standard 12 column grid.

 

 
 

Using a 12 grid container, regardless of how nested the layout containers are

So this was interesting. As an author, I believe I should have the ability to select the grid column count for the nested layout container. Granted, there might not be many use-cases to jamming 12 columns in such a tight space, but it would be nice to have fine control if I so desire.
With that in mind, my coworker Paul and I discussed this and he showed me exactly how the grid width is stored.
Obligatory shout out to Paul Bjorkstrand!.
Every time an author makes a layout change via the editor, a new node called cq:responsive is stored under that component. Under that you’d see the breakpoint nodes. Each node contains a property called “width” which dictates the component column grid column count, in the case of the layout container.

 
Additionally, looking at the JS API, there is a namespace: Granite.authorresponsive.persistence that has several methods to make layout changes. The one that interested me was: Granite.authorresponsive.persistence.setBreakpointConfig(editable, breakpoint, config) where:

  • editable is the Granite.author.Editable
  • breakpoint is the breakpoint name, like “default”, “phone” or whatever is the breakpoint you have defined. we can use Granite.author.responsive#getCurrentBreakpoint to get this at any point.
  • config is an object with the properties that need to be stored into the relevant cq:responsive node of the editable

A ToolbarAction Solution!

With this information, I can now build a ToolbarAction to make this change for me and set the layout container to any width I want. Here is a demo:

 
And here is the code that builds this ToolbarAction:
Add this to a clientlib with category cq.authoring.editor.hook

This code is heavily based on the code in ACS commons for PanelSelector. That code helped me make this a lot faster since the general layout and design is the same (toolbar action with a popover), but my toolbar action does something different.

(function ($, ns, channel, window) {
  "use strict";
  var columnSelector;
  var NS_COLUMNSELECTOR = ".cmp-columnelector";
  var POPOVER_MIN_WIDTH = "4rem"; // looks better
  /**
   * @typedef {Object} ColumnSelectorConfig Represents a Column Selector configuration object
   * @property {Granite.author.Editable} editable The [Editable]{@link Granite.author.Editable} against which to create the column selector
   * @property {HTMLElement} target The target against which to attach the column selector UI
   */
  var ColumnSelector = ns.util.createClass({
    /**
       * The Column Selector configuration Object
       *
       * @member {ColumnSelectorConfig} ColumnSelector#_config
       */
    _config: {},
    /**
     * An Object that is used to cache the internal HTMLElements for this Column Selector
     *
     * @member {Object} ColumnSelector#_elements
     */
    _elements: {},
    constructor: function ColumnSelector(config) {
      var that = this;
      that._handleOutOfAreaClickBound = that._handleOutOfAreaClick.bind(that)
      that._handleButtonClickBound = that._handleButtonClick.bind(that)
      that._config = config;
      that._render();
      that._bindEvents();
    },
    // if the column selector is open
    isOpen: function () {
      return (this._elements.popover && this._elements.popover.open);
    },
    /**
     * Renders the Column Selector, adds its items and attaches it to the DOM
     *
     * @private
     */
    _render: function () {
      this._elements.popover = this._createPopover();
      this._elements.buttonList = this._createColumnButtonList();
      // append column list to the popover
      this._elements.popover.content.appendChild(this._elements.buttonList);
      // append popover to the content frame for re-use.
      ns.ContentFrame.scrollView[0].appendChild(this._elements.popover);
    },
    /**
     * Creates a simple [Coral.Popover]{@link Coral.Popover}
     *
     * @private
     * @returns {Coral.Popover} a simple coral popover.
     */
    _createPopover: function () {
      var that = this;
      var popover = new Coral.Popover().set({
        alignAt: Coral.Overlay.align.LEFT_BOTTOM,
        alignMy: Coral.Overlay.align.LEFT_TOP,
        target: that._config.target,
        interaction: Coral.Popover.interaction.OFF,
        open: true
      });
      // this is 9.375rem by default.. didnt want to css it
      popover.style.minWidth = POPOVER_MIN_WIDTH;
      return popover;
    },
    /**
     * Creates the [Coral.ButtonList]{@link Coral.ButtonList}; 12 buttons for 12 columns)
     *
     * @private
     * @returns {Coral.ButtonList}  List of buttons of all 12 columns.
     */
    _createColumnButtonList: function() {
      var buttonList = new Coral.ButtonList().set({
        interaction: Coral.ButtonList.interaction.OFF
      });
      // columns 1...12
      [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
        .map(function (n) {
          var btnItem = new Coral.ButtonList.Item()
            .set({
              innerHTML: n,
              value: n
            })
          btnItem.style.textAlign = "center"
          btnItem.style.width = "100%";
          return btnItem;
        })
        .forEach(function (listItem) {
          return buttonList.items.add(listItem)
        });
      return buttonList;
    },
    /**
     * Binds interaction events
     *
     * @private
     */
    _bindEvents: function () {
      var that = this;
      // escape key
      $(document).off("keyup" + NS_COLUMNSELECTOR).on("keyup" + NS_COLUMNSELECTOR, function (event) {
        if (event.keyCode === 27) {
          that._finish();
        }
      });
      // out of area clicks
      document.removeEventListener("click", that._handleOutOfAreaClickBound);
      document.addEventListener("click", that._handleOutOfAreaClickBound, true);
      // click handlers for each button
      this._elements.buttonList.items.getAll()
        .forEach(function (buttonItem) {
          buttonItem.addEventListener("click", that._handleButtonClickBound, true);
        });
      // reposition the popover with overlay change,
      // as the editable toolbar can jump following navigation to a panel
      channel.off("cq-overlays-repositioned" + NS_COLUMNSELECTOR).on("cq-overlays-repositioned" + NS_COLUMNSELECTOR, function () {
        if (that._elements.popover) {
          that._elements.popover.reposition();
        }
      });
    },
    /**
     * Handles click on a clolumn button.
     * @param {Event} e the button click event.
     */
    _handleButtonClick: function (e) {
      var that = this;
      var breakpoint = ns.responsive.getCurrentBreakpoint();
      // setBreakpointConfig is not documented in the following url, but works nonetheless:
      // https://helpx.adobe.com/experience-manager/6-4/sites/developing/using/reference-materials/jsdoc/ui-touch/editor-core/index.html
      ns.responsive.persistence.setBreakpointConfig(that._config.editable, breakpoint, { width: e.target.value })
        .done(function () {
          // refresh after making the change.
          ns.edit.EditableActions.REFRESH.execute(that._config.editable).done(function () {
            that._config.editable.overlay.setSelected(true);
          });
        });
      that._finish();
    },
    /**
     * Handles clicks outside of the Column Selector popover
     *
     * @private
     * @param {Event} event The click event
     */
    _handleOutOfAreaClick: function (event) {
      console.log("closing..")
      var that = this;
      if (!$(event.target).closest(that._elements.popover).length) {
        that._finish();
      }
    },
    /**
     * Unbinds event handlers
     *
     * @private
     */
    _unbindEvents: function () {
      var that = this;
      $(document).off("click" + NS_COLUMNSELECTOR);
      // unbind click events here
      this._elements.buttonList.items.getAll().forEach(function (buttonItem) {
        buttonItem.removeEventListener("click", that._handleButtonClick);
      })
    },
    /**
     * Finishes column selection, hides it and cleans up.
     *
     * @private
     */
    _finish: function () {
      var that = this;
      if (that._elements.popover && that._elements.popover.parentNode) {
        that._elements.popover.open = false;
        that._unbindEvents();
        that._elements.popover.parentNode.removeChild(that._elements.popover);
      }
    }
  });
  /**
   * Toolbar action that works on responsivegrid component to allow authors
   * to chose the grid column width/count
   */
  var columnSelect = new ns.ui.ToolbarAction({
    name: "COLUMN_SELECT",
    text: Granite.I18n.get("Select Column"),
    icon: "tableEdit",
    execute: function (editable, param, target) {
      if (!columnSelector || !columnSelector.isOpen()) {
        columnSelector = new ColumnSelector({
          "editable": editable,
          "target": target[0]
        });
      }
      // do not close the toolbar
      return false;
    },
    // make sure this is responsivegrid and that current component does not have live relationship.
    condition: function (editable) {
      if (editable.type !== "wcm/foundation/components/responsivegrid") {
        return false;
      }
      if (editable.config[MSM.MSMCommons.Constants.PROP_LIVE_RELATIONSHIP]) {
        return MSM.MSMCommons.isInheritanceCancelled(editable) || MSM.MSMCommons.isManuallyCreated(editable);
      } else {
        return true
      }
    },
    isNonMulti: true
  });
  channel.on("cq-layer-activated", function (event) {
    if (event.layer === "Edit") {
      ns.EditorFrame.editableToolbar.registerAction("COLUMN_SELECT", columnSelect);
    }
  });
}(jQuery, Granite.author, jQuery(document), this));

The code is well documented and should be easy to follow. This was a lot of fun to build and I hope to make more toolbar actions in the future!

Thoughts on “Having Some Fun With The New AEM Layout Editor”

  1. I’m not sure that adding this js under /libs/granite/contexthub/components/contexthub/authoring-hook would work.
    I suggest you add the JS in a new clientlib with the suggested category in the post. Then edit a page and inspect to see the JS code is actually loading.

  2. Hi Ahmed, tried putting the js in a clientlib . When the custom button is clicked author mode flags an error

    An error has occured during execution of the selected action: Coral.ButtonList.interaction is undefined -> _createColumnButtonList@http://localhost:4502/libs/cq/gui/components/authoring/editors/clientlibs/sites/page.js:7671:9

    Can you please share a package, so i can test it on 6.3 and 6.4
    Thanks

  3. Hi,

    I’ve built this for fun and it is not mean to be used in an actual production application. Some of the APIs and the approaches I use depend on hidden implementation details of the Layout Editor. Meaning, this can change or break with an upcoming SP. And if you want to build and run this, you’d have to do it at your own risk 🙂

    I believe I have built and tested this on AEM 6.5.0

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Ahmed Musallam, Adobe Technical Lead

Ahmed is an Adobe Technical Lead and expert in the Adobe Experience Cloud.

More from this Author

Categories
Follow Us
TwitterLinkedinFacebookYoutubeInstagram