Blockly Centerline Renderer

Published 10/19/19

Version 3.20191014 of Blockly just came out a few days ago (as of the time of writing) and with it came a new rendering engine.

The first thing that came to mind when I saw this new API was making a renderer where all of the block’s elements were lined up in rows, so that it would mimic a text editor.

This was a longstanding issue on the Blockly github, and it seemed like the perfect time to give it a shot.

Creating a new Renderer

The first thing you’ll want to do when creating a new renderer is copy all of the minimalist renderer files. The minimalist renderer is essentially just a wrapper for the abstract renderer (called common) and it works great as a template.

Then you’ll want to rename all reference to ‘minimalist’ to whatever you want to call your renderer (I chose ‘baseline’). And then rebuild blockly, because you added new files.

I also removed the unnecessary comments and constants which left me with the following four files:

goog.provide('Blockly.baseline.ConstantProvider');

goog.require('Blockly.blockRendering.ConstantProvider');
goog.require('Blockly.utils.object');


/**
 * An object that provides constants for rendering blocks in the sample.
 * @constructor
 * @package
 * @extends {Blockly.blockRendering.ConstantProvider}
 */
Blockly.baseline.ConstantProvider = function() {
  Blockly.baseline.ConstantProvider.superClass_.constructor.call(this);

  /**
   * The output tab's vertical offset from the top of the block.
   *
   * Not used by the baseline renderer.
   * @type {number}
   */
  this.TAB_OFFSET_FROM_TOP = 0;
};
Blockly.utils.object.inherits(Blockly.baseline.ConstantProvider,
    Blockly.blockRendering.ConstantProvider);
goog.provide('Blockly.baseline.Drawer');

goog.require('Blockly.baseline.RenderInfo');
goog.require('Blockly.blockRendering.Drawer');
goog.require('Blockly.utils.object');


/**
 * An object that draws a block based on the given rendering information.
 * @param {!Blockly.BlockSvg} block The block to render.
 * @param {!Blockly.baseline.RenderInfo} info An object containing all
 *   information needed to render this block.
 * @package
 * @constructor
 * @extends {Blockly.blockRendering.Drawer}
 */
Blockly.baseline.Drawer = function(block, info) {
  Blockly.baseline.Drawer.superClass_.constructor.call(this, block, info);
};
Blockly.utils.object.inherits(Blockly.baseline.Drawer,
    Blockly.blockRendering.Drawer);
goog.provide('Blockly.baseline');
goog.provide('Blockly.baseline.RenderInfo');

goog.require('Blockly.utils.object');


/**
 * An object containing all sizing information needed to draw this block.
 *
 * This measure pass does not propagate changes to the block (although fields
 * may choose to rerender when getSize() is called).  However, calling it
 * repeatedly may be expensive.
 *
 * @param {!Blockly.baseline.Renderer} renderer The renderer in use.
 * @param {!Blockly.BlockSvg} block The block to measure.
 * @constructor
 * @package
 * @extends {Blockly.blockRendering.RenderInfo}
 */
Blockly.baseline.RenderInfo = function(renderer, block) {
  Blockly.baseline.RenderInfo.superClass_.constructor.call(this, renderer, block);
};
Blockly.utils.object.inherits(Blockly.baseline.RenderInfo,
    Blockly.blockRendering.RenderInfo);
/**
 * The baseline renderer.
 * @package
 * @constructor
 * @extends {Blockly.blockRendering.Renderer}
 */
Blockly.baseline.Renderer = function() {
  Blockly.baseline.Renderer.superClass_.constructor.call(this);
};
Blockly.utils.object.inherits(Blockly.baseline.Renderer,
    Blockly.blockRendering.Renderer);

/**
 * Create a new instance of the renderer's constant provider.
 * @return {!Blockly.baseline.ConstantProvider} The constant provider.
 * @protected
 * @override
 */
Blockly.baseline.Renderer.prototype.makeConstants_ = function() {
  return new Blockly.baseline.ConstantProvider();
};

/**
 * Create a new instance of the renderer's render info object.
 * @param {!Blockly.BlockSvg} block The block to measure.
 * @return {!Blockly.baseline.RenderInfo} The render info object.
 * @protected
 * @override
 */
Blockly.baseline.Renderer.prototype.makeRenderInfo_ = function(block) {
  return new Blockly.baseline.RenderInfo(this, block);
};

/**
 * Create a new instance of the renderer's drawer.
 * @param {!Blockly.BlockSvg} block The block to render.
 * @param {!Blockly.blockRendering.RenderInfo} info An object containing all
 *   information needed to render this block.
 * @return {!Blockly.baseline.Drawer} The drawer.
 * @protected
 * @override
 */
Blockly.baseline.Renderer.prototype.makeDrawer_ = function(block, info) {
  return new Blockly.baseline.Drawer(block,
      /** @type {!Blockly.baseline.RenderInfo} */ (info));
};

Blockly.blockRendering.register('baseline', Blockly.baseline.Renderer);

Deciding our objective

Now before we get too far into things, we need to define exactly what it is we want. We’re starting with something that looks like this:

The minimalist renderer, in all of its semi-disorganized glory

The first thing that jumps out to me is that we’re going to want to move the output connections down so that they’re centered with the inline inputs.

The external input rows should align with the top row of their leaf-most block (meaning the most nested one).

And in cases where there’s a block with multiple rows inside of an inline input, it’s top input row should align with the top row of the other blocks in its parent.

This is going to require a lot of changes. We’re going to have to change how inline inputs, external inputs, and output connections render.

Well, let’s get to it!

Inline inputs: centering the connection tabs

Firstly, setting the TAB_OFFSET_FROM_TOP constant to zero broke inline input tabs, so let’s fix that really quick.

I did that by defining two new constants in my constants.js file, and overriding the drawInlineInput_ function in drawer.js.

/**
   * An empty inline input's tab's offset from the top of the input.
   * @type {number}
   */
  this.EMPTY_INLINE_INPUT_TAB_OFFSET_FROM_TOP = 5;

  /**
   * The height of an inline input.
   *
   * Equal to tabHeight + tabOffset * 2, so that the tab has equal
   * offsets on the top and bottom.
   * @type {number}
   */
  this.EMPTY_INLINE_INPUT_HEIGHT = this.TAB_HEIGHT +
      this.EMPTY_INLINE_INPUT_TAB_OFFSET_FROM_TOP * 2;
/**
 * Add steps for an inline input.
 * @param {!Blockly.blockRendering.InlineInput} input The information about the
 * input to render.
 * @protected
 */
Blockly.baseline.Drawer.prototype.drawInlineInput_ = function(input) {
  var width = input.width;
  var height = input.height;
  var yPos = input.centerline - height / 2;


  var connectionTop = this.constants_.EMPTY_INLINE_INPUT_TAB_OFFSET_FROM_TOP;
  var connectionBottom = connectionTop + input.connectionHeight;
  var connectionRight = input.xPos + input.connectionWidth;

  this.inlinePath_ += Blockly.utils.svgPaths.moveTo(connectionRight, yPos) +
    Blockly.utils.svgPaths.lineOnAxis('v', connectionTop) +
    input.shape.pathDown +
    Blockly.utils.svgPaths.lineOnAxis('v', height - connectionBottom) +
    Blockly.utils.svgPaths.lineOnAxis('h', width - input.connectionWidth) +
    Blockly.utils.svgPaths.lineOnAxis('v', -height) +
    'z';

  this.positionInlineInputConnection_(input);
};

Much better!

Output connections: aligning with inline inputs

Now let’s bump the output down to align with the inputs.

Essentially, what we want is to create a line that goes through all of the blocks.

To achieve that we’re going to assign a centerline property to each of the rows, and to the block itself.

In the simplest situation (where the row doesn’t have any children) the centerline is just half of the row’s height.

Then we set the centerline property of the block to the centerline of its top input row.

We could also set it to the centerline of a different row, such as the bottom:

But we said we wanted things to be aligned with the top row, so that’s what we’re doing.

Now we need to handle the more complicated situation, where the row does have children. In this case we set the centerline of the row equal to the deepest centerline out of all its children (blocks with shallower centerlines can be moved down to match).

Note that the example photos are of the finalized renderer. We haven’t fixed inline inputs yet, we’re just figuring out output tabs.

Now that we know what we’re doing, we need to find some place to put it. Since we need the row’s height to handle the simple case, the best place I found to put this was at the end of computeBounds_, where the height has just been calculated.

That gives us the following code:

/**
 * Apply a centerline to the different rows and to the block once the height
 * of the row has been determined.
 * @protected
 */
Blockly.baseline.RenderInfo.prototype.computeBounds_ = function() {
  Blockly.baseline.RenderInfo.superClass_.computeBounds_.call(this);
  for (var i = 0, row; row = this.rows[i]; i++) {
    row.centerline = 0;
    var foundTarget = false;
    if (row.hasInlineInput) {
      // Each piece of UI on a block is considered an element.
      var elements = row.elements;
      for (var i = 0, element; element = elements[i]; i++) {
        // That includes inline inputs.
        if (Blockly.blockRendering.Types.isInlineInput(element)) {
          // This element is a blockRendering.InlineInput which holds a
          // Blockly.Input
          var target = element.input.connection.targetBlock();
          if (target) {
            foundTarget = true;
            // This is how we find the lowest centerline.
            row.centerline = Math.max(row.centerline, target.centerline);
          }
        }
      }
    }

    if (!foundTarget) {
      row.centerline += row.height / 2;
    }
  }
  // And cache the firstInputRow for later use.
  // We need to store this here because later spacer rows will be added,
  // which will mess up the indexing.
  this.firstInputRow = this.rows[1];
};

/**
 * Make any final changes to the rendering information object.  In particular,
 * store the y position of each row, and record the height of the full block.
 * @protected
 */
Blockly.baseline.RenderInfo.prototype.finalize_ = function() {
  Blockly.baseline.RenderInfo.superClass_.finalize_.call(this);
  var firstInputRow = this.firstInputRow;
  // And store the centerline of the block. We have to do this at the end
  // because the firstInputRow's yPos could have changed since computeBounds_
  // (because of spacer rows and the like).
  this.block_.centerline = firstInputRow.yPos + firstInputRow.centerline;
};
/**
 * Add steps for the left side of the block, which may include an output
 * connection
 * @protected
 */
Blockly.baseline.Drawer.prototype.drawLeft_ = function() {
  var outputConnection = this.info_.outputConnection;
  this.positionOutputConnection_();

  if (outputConnection) {
    // Find the bottom of the tab, relative to the top of the block.
    var center = this.block_.centerline;
    var tabHeight = outputConnection.shape.height;
    var halfTabHeight = tabHeight / 2;
    var tabBottom = center + halfTabHeight;

    this.outlinePath_ +=
        Blockly.utils.svgPaths.lineOnAxis('V', tabBottom) +
        outputConnection.shape.pathUp;
  }
  // This connects us back to the top, whether we have an output or not.
  this.outlinePath_ += 'z';
};

Inline inputs: aligning the connection

But now we have a bit of a problem, which is that our inline input tabs aren’t lining up:

We can see from the render debugger that our connections are lining up, it’s just that the input tabs aren’t being drawn in the right place.

That makes sense, because while our outputs are obeying the centerline, our inline inputs are not. Shouldn’t be too hard to fix!

Blockly.baseline.Drawer.prototype.drawInlineInput_ = function(input) {
  // ...

  var connectionTop = this.constants_.EMPTY_INLINE_INPUT_TAB_OFFSET_FROM_TOP;
  // Note that the input is a blockRendering.InlineInput
  // which holds a Blockly.Input.
  var target = input.input.connection.targetBlock();
  if (target) {
    connectionTop = target.centerline - input.connectionHeight / 2;
  }
  // ...
}

Now the tops of our inline input tabs will be correctly positioned.

Inline inputs: centering

It looks like we still have another problem with our inline inputs, which is that the first input rows of the child blocks are not aligned.

This needs to be handled in the getElemCenterline_ method, because if it isn’t our centerline will get overwritten.

/**
 * Calculate the centerline of an element in a rendered row.
 * @param {!Blockly.blockRendering.Row} row The row containing the element.
 * @param {!Blockly.blockRendering.Measurable} elem The element to place.
 * @return {number} The desired centerline of the given element, as an offset
 *     from the top left of the block.
 * @protected
 */
Blockly.baseline.RenderInfo.prototype.getElemCenterline_ = function(row, elem) {
  // Handle rows that didn’t get centerlines (like spacer rows).
  if (!row.centerline) {
    return row.yPos + row.height / 2;
  }
  var offset = 0;
  if (Blockly.blockRendering.Types.isInlineInput(elem)) {
    var target = elem.input.connection.targetBlock();
    if (target && target.centerline != elem.height / 2) {
      // The difference between the element's natural centerline
      // (elem.height / 2) and where it should be (target.centerline)
      offset = elem.height / 2 - target.centerline;
      // The amount of the target below the target's centerline.
      var hangingHeight = target.height - target.centerline;
      var elementBottom = row.centerline + hangingHeight;
      // If the element is beyond the bounds of the row.
      if (elementBottom > row.height) {
        row.height = elementBottom;
      }
    }
  }
  return row.centerline + row.yPos + offset;
};

As you can see, in doing this we need to modify the height of the row, because if we don’t we end up with a block that dangles outside the bounds of its parent.

But, because we do that it means we also need to fix issue #3249 (at least at the time of writing). So our finalize method will now look like the following:

Blockly.baseline.RenderInfo.prototype.finalize_ = function() {
  // This is all duplicated, the `yCursor += row.height;` line is just moved
  // to the end of the loop.
  var widestRowWithConnectedBlocks = 0;
  var yCursor = 0;
  for (var i = 0, row; (row = this.rows[i]); i++) {
    row.yPos = yCursor;
    row.xPos = this.startX;

    widestRowWithConnectedBlocks =
      Math.max(widestRowWithConnectedBlocks, row.widthWithConnectedBlocks);
    this.recordElemPositions_(row);
    yCursor += row.height;
  }

  this.widthWithChildren = widestRowWithConnectedBlocks + this.startX;

  this.height = yCursor;
  this.startY = this.topRow.capline;
  this.bottomRow.baseline = yCursor - this.bottomRow.descenderHeight;

  // Here's the stuff that isn't in the base function.
  var firstInputRow = this.firstInputRow;
  this.block_.centerline = firstInputRow.yPos + firstInputRow.centerline;
};

External inputs: aligning the connection

Remember that inline input tab alignment problem we had? It seems like the same problem is happening with our external inputs. Luckily the same solution applies.

/**
 * Add steps for an external value input, rendered as a notch in the side
 * of the block.
 * @param {!Blockly.blockRendering.Row} row The row that this input
 *     belongs to.
 * @protected
 */
Blockly.blockRendering.Drawer.prototype.drawValueInput_ = function(row) {
  var input = row.getLastInput();
  this.positionExternalValueConnection_(row);

  // Remember that in this case we're drawing down instead of up.
  var pathDown = (typeof input.shape.pathDown == "function") ?
    input.shape.pathDown(input.height) :
    input.shape.pathDown;

  // Once again this is a blockRendering.InputConnection not a Blockly.Input
  var target = input.input.connection.targetBlock();
  var topOffset = 0;
  if (target) {
    // The distance from the top of the block to the top of the input tab.
    topOffset = target.centerline - input.connectionHeight / 2;
    this.outlinePath_ +=
      Blockly.utils.svgPaths.lineOnAxis('v', topOffset);
  }

  this.outlinePath_ +=
    Blockly.utils.svgPaths.lineOnAxis(
        'H', input.xPos + input.width) +
    pathDown +
    Blockly.utils.svgPaths.lineOnAxis(
        'v', row.height - input.connectionHeight - topOffset);
};

Output connections: aligning with external inputs

Since we’re messing with externals, that reminds me that we’re going to want rows to align with the top row of a block attached to an external input.

To get that working we’ll just set the row’s centerline to the connected block, similarly to what we do with inline inputs.

Blockly.baseline.RenderInfo.prototype.computeBounds_ = function() {
  Blockly.baseline.RenderInfo.superClass_.computeBounds_.call(this);
  for (var i = 0, row; row = this.rows[i]; i++) {
    row.centerline = 0;
    var foundTarget;
    if (row.hasInlineInput) {
      // ...
    } else if (row.hasExternalInput) {
      // These lines added.
      var input = row.getLastInput();
      var target = input.input.connection.targetBlock();
      if (target) {
        foundTarget = true;
        row.centerline = target.centerline;
      }
    }

    if (!foundTarget) {
      row.centerline += row.height / 2;
    }
  }
  this.firstInputRow = this.rows[1];
};

Connection positions

Now all of our blocks are rendering properly! But there’s just one more thing we need to fix, and that’s the position of our RenderedConnections.

Contrary to what you might think, RenderedConnections don’t actually have an svg associated with them, but they do have a location which is used in the ConnectionDB.

Currently they’re always placed at the top of the input/output.

Which causes two problems.

  1. If the connector tabs get too close, they can’t connect.
  2. The highlight is placed incorrectly.

To fix this we just need to override the positioning logic for the output, external input, and inline input connections.

/**
 * Position the output connection on a block.
 * @param {!Blockly.blockRendering.OutputConnection} outputConnection The
 *    measurable containing the connection we want to position.
 * @param {number} connectionBottom The position of the bottom of the output
 *    connection.
 * @protected
 */
Blockly.baseline.Drawer.prototype.positionOutputConnection_ =
  function(outputConnection, connectionBottom) {
    var x = this.info_.startX;
    var connX = this.info_.RTL ? -x : x;
    var connY = connectionBottom - outputConnection.shape.height;
    this.block_.outputConnection.setOffsetInBlock(connX, connY);
  };

/** ... */
Blockly.baseline.Drawer.prototype.drawLeft_ = function() {
  var outputConnection = this.info_.outputConnection;
  // The line used to be here.

  if (outputConnection) {
    var center = this.block_.centerline;
    var tabHeight = outputConnection.shape.height;
    var halfTabHeight = tabHeight / 2;
    var tabBottom = center + halfTabHeight;

    // Now it is here, and it passes tabBottom.
    this.positionOutputConnection_(outputConnection, tabBottom);
    this.outlinePath_ +=
        Blockly.utils.svgPaths.lineOnAxis('V', tabBottom) +
        outputConnection.shape.pathUp;
  }

  this.outlinePath_ += 'z';
};
/**
 * Position the connection on an inline value input.
 * @param {Blockly.blockRendering.InlineInput} input The information about
 * the input that the connection is on.
 * @param {number} topOffset The vertical offset the connection has from the
 *    top of the row.
 * @protected
 */
Blockly.baseline.Drawer.prototype.positionInlineInputConnection_ =
  function(input, topOffset) {
    if (!input.connection) {
      return;
    }
    var top = input.centerline - input.height / 2;
    var connY = top + topOffset;
    // xPos already contains info about startX
    var connX = input.xPos + input.connectionWidth;
    if (this.info_.RTL) {
      connX *= -1;
    }
    input.connection.setOffsetInBlock(connX, connY);
  };

/** ... */
Blockly.baseline.Drawer.prototype.drawInlineInput_ = function(input) {
  // ....
  
  // This also passes connectionTop now.
  this.positionInlineInputConnection_(input, connectionTop);
};
/**
 * Position the connection on an external value input.
 * @param {!Blockly.blockRendering.Row} row The row that the connection is on.
 * @param {!Blockly.Input} input The input the connection belongs to.
 * @param {number} topOffset The vertical offset the connection has from the
 *    top of the row.
 * @protected
 */
Blockly.baseline.Drawer.prototype.positionExternalValueConnection_ =
  function(row, input, topOffset) {
    if (!input.connection) {
      return;
    }
    var connY = row.yPos + topOffset;
    var connX = row.xPos + row.width;
    if (this.info_.RTL) {
      connX *= -1;
    }
    input.connection.setOffsetInBlock(connX, connY);
  };

Blockly.baseline.Drawer.prototype.drawValueInput_ = function(row) {
  var input = row.getLastInput();
  // The line used to be up here.

  var pathDown = (typeof input.shape.pathDown == "function") ?
    input.shape.pathDown(input.height) :
    input.shape.pathDown;

  var target = input.connection.targetBlock();
  var topOffset = 0;
  if (target) {
    topOffset = target.centerline - input.connectionHeight / 2;
    this.outlinePath_ +=
      Blockly.utils.svgPaths.lineOnAxis('v', topOffset);
  }

  // Now it's down here, and it passes some extra values.
  this.positionExternalValueConnection_(row, input, topOffset);

  this.outlinePath_ +=
    Blockly.utils.svgPaths.lineOnAxis(
        'H', input.xPos + input.width) +
    pathDown +
    Blockly.utils.svgPaths.lineOnAxis(
        'v', row.height - input.connectionHeight - topOffset);
};

Conclusion

We have done it! We created a renderer that renders everything on one line, and it was actually pretty easy! I’m really excited to see what other people do with the new renderer API :D

And here is a little demo for your enjoyment.