/** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as goog from '../../../closure/goog/goog.js'; goog.declareModuleId('Blockly.blockRendering.RenderInfo'); import type {BlockSvg} from '../../block_svg.js'; import {Input} from '../../inputs/input.js'; import {Align} from '../../inputs/align.js'; import type {RenderedConnection} from '../../rendered_connection.js'; import type {Measurable} from '../measurables/base.js'; import {BottomRow} from '../measurables/bottom_row.js'; import {DummyInput} from '../../inputs/dummy_input.js'; import {ExternalValueInput} from '../measurables/external_value_input.js'; import {Field} from '../measurables/field.js'; import {Hat} from '../measurables/hat.js'; import {Icon} from '../measurables/icon.js'; import {InRowSpacer} from '../measurables/in_row_spacer.js'; import {InlineInput} from '../measurables/inline_input.js'; import {InputRow} from '../measurables/input_row.js'; import {JaggedEdge} from '../measurables/jagged_edge.js'; import {NextConnection} from '../measurables/next_connection.js'; import {OutputConnection} from '../measurables/output_connection.js'; import {PreviousConnection} from '../measurables/previous_connection.js'; import {RoundCorner} from '../measurables/round_corner.js'; import type {Row} from '../measurables/row.js'; import {SpacerRow} from '../measurables/spacer_row.js'; import {SquareCorner} from '../measurables/square_corner.js'; import {StatementInput as StatementInputMeasurable} from '../measurables/statement_input.js'; import {StatementInput} from '../../inputs/statement_input.js'; import {TopRow} from '../measurables/top_row.js'; import {Types} from '../measurables/types.js'; import {ValueInput} from '../../inputs/value_input.js'; import type {ConstantProvider} from './constants.js'; import type {Renderer} from './renderer.js'; /** * 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. */ export class RenderInfo { block_: BlockSvg; protected constants_: ConstantProvider; outputConnection: OutputConnection | null; isInline: boolean; isCollapsed: boolean; isInsertionMarker: boolean; RTL: boolean; /** The block renderer in use. */ protected readonly renderer_!: Renderer; /** The height of the rendered block, including child blocks. */ height = 0; /** The width of the rendered block, including child blocks. */ widthWithChildren = 0; /** * The width of the rendered block, excluding child blocks. This is the * right edge of the block when rendered LTR. */ width = 0; statementEdge = 0; /** An array of Row objects containing sizing information. */ rows: Row[] = []; /** An array of input rows on the block. */ inputRows: InputRow[] = []; topRow: TopRow; bottomRow: BottomRow; // The position of the start point for drawing, relative to the block's // location. startX = 0; startY = 0; /** * @param renderer The renderer in use. * @param block The block to measure. */ constructor(renderer: Renderer, block: BlockSvg) { this.renderer_ = renderer; this.block_ = block; /** The renderer's constant provider. */ this.constants_ = this.renderer_.getConstants(); /** * A measurable representing the output connection if the block has one. * Otherwise null. */ this.outputConnection = block.outputConnection ? new OutputConnection(this.constants_, block.outputConnection) : null; /** * Whether the block should be rendered as a single line, either because * it's inline or because it has been collapsed. */ this.isInline = block.getInputsInline() && !block.isCollapsed(); /** Whether the block is collapsed. */ this.isCollapsed = block.isCollapsed(); /** * Whether the block is an insertion marker. Insertion markers are the same * shape as normal blocks, but don't show fields. */ this.isInsertionMarker = block.isInsertionMarker(); /** True if the block should be rendered right-to-left. */ this.RTL = block.RTL; /** * An object with rendering information about the top row of the block. */ this.topRow = new TopRow(this.constants_); /** * An object with rendering information about the bottom row of the block. */ this.bottomRow = new BottomRow(this.constants_); } /** * Get the block renderer in use. * * @returns The block renderer in use. */ getRenderer(): Renderer { return this.renderer_; } /** * Populate and return 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. */ measure() { this.createRows_(); this.addElemSpacing_(); this.addRowSpacing_(); this.computeBounds_(); this.alignRowElements_(); this.finalize_(); } /** * Create rows of Measurable objects representing all renderable parts of the * block. */ protected createRows_() { this.populateTopRow_(); this.rows.push(this.topRow); let activeRow = new InputRow(this.constants_); this.inputRows.push(activeRow); // Icons always go on the first row, before anything else. const icons = this.block_.getIcons(); for (let i = 0, icon; (icon = icons[i]); i++) { const iconInfo = new Icon(this.constants_, icon); if (!this.isCollapsed || icon.isShownWhenCollapsed()) { activeRow.elements.push(iconInfo); } } let lastInput = undefined; // Loop across all of the inputs on the block, creating objects for anything // that needs to be rendered and breaking the block up into visual rows. for (let i = 0, input; (input = this.block_.inputList[i]); i++) { if (!input.isVisible()) { continue; } if (this.shouldStartNewRow_(input, lastInput)) { // Finish this row and create a new one. this.rows.push(activeRow); activeRow = new InputRow(this.constants_); this.inputRows.push(activeRow); } // All of the fields in an input go on the same row. for (let j = 0, field; (field = input.fieldRow[j]); j++) { activeRow.elements.push(new Field(this.constants_, field, input)); } this.addInput_(input, activeRow); lastInput = input; } if (this.isCollapsed) { activeRow.hasJaggedEdge = true; activeRow.elements.push(new JaggedEdge(this.constants_)); } if (activeRow.elements.length || activeRow.hasDummyInput) { this.rows.push(activeRow); } this.populateBottomRow_(); this.rows.push(this.bottomRow); } /** * Create all non-spacer elements that belong on the top row. */ protected populateTopRow_() { const hasPrevious = !!this.block_.previousConnection; const hasHat = (this.block_.hat ? this.block_.hat === 'cap' : this.constants_.ADD_START_HATS) && !this.outputConnection && !hasPrevious; let cornerClass = this.topRow.hasLeftSquareCorner(this.block_) ? SquareCorner : RoundCorner; this.topRow.elements.push(new cornerClass(this.constants_)); if (hasHat) { const hat = new Hat(this.constants_); this.topRow.elements.push(hat); this.topRow.capline = hat.ascenderHeight; } else if (hasPrevious) { this.topRow.hasPreviousConnection = true; this.topRow.connection = new PreviousConnection( this.constants_, this.block_.previousConnection as RenderedConnection ); this.topRow.elements.push(this.topRow.connection); } const precedesStatement = this.block_.inputList.length && this.block_.inputList[0] instanceof StatementInput; // This is the minimum height for the row. If one of its elements has a // greater height it will be overwritten in the compute pass. if (precedesStatement && !this.block_.isCollapsed()) { this.topRow.minHeight = this.constants_.TOP_ROW_PRECEDES_STATEMENT_MIN_HEIGHT; } else { this.topRow.minHeight = this.constants_.TOP_ROW_MIN_HEIGHT; } cornerClass = this.topRow.hasRightSquareCorner(this.block_) ? SquareCorner : RoundCorner; this.topRow.elements.push(new cornerClass(this.constants_, 'right')); } /** * Create all non-spacer elements that belong on the bottom row. */ protected populateBottomRow_() { this.bottomRow.hasNextConnection = !!this.block_.nextConnection; const followsStatement = this.block_.inputList.length && this.block_.inputList[this.block_.inputList.length - 1] instanceof StatementInput; // This is the minimum height for the row. If one of its elements has a // greater height it will be overwritten in the compute pass. if (followsStatement) { this.bottomRow.minHeight = this.constants_.BOTTOM_ROW_AFTER_STATEMENT_MIN_HEIGHT; } else { this.bottomRow.minHeight = this.constants_.BOTTOM_ROW_MIN_HEIGHT; } const leftSquareCorner = this.bottomRow.hasLeftSquareCorner(this.block_); if (leftSquareCorner) { this.bottomRow.elements.push(new SquareCorner(this.constants_)); } else { this.bottomRow.elements.push(new RoundCorner(this.constants_)); } if (this.bottomRow.hasNextConnection) { this.bottomRow.connection = new NextConnection( this.constants_, this.block_.nextConnection as RenderedConnection ); this.bottomRow.elements.push(this.bottomRow.connection); } const rightSquareCorner = this.bottomRow.hasRightSquareCorner(this.block_); if (rightSquareCorner) { this.bottomRow.elements.push(new SquareCorner(this.constants_, 'right')); } else { this.bottomRow.elements.push(new RoundCorner(this.constants_, 'right')); } } /** * Add an input element to the active row, if needed, and record the type of * the input on the row. * * @param input The input to record information about. * @param activeRow The row that is currently being populated. */ protected addInput_(input: Input, activeRow: Row) { // Non-dummy inputs have visual representations onscreen. if (this.isInline && input instanceof ValueInput) { activeRow.elements.push(new InlineInput(this.constants_, input)); activeRow.hasInlineInput = true; } else if (input instanceof StatementInput) { activeRow.elements.push( new StatementInputMeasurable(this.constants_, input) ); activeRow.hasStatement = true; } else if (input instanceof ValueInput) { activeRow.elements.push(new ExternalValueInput(this.constants_, input)); activeRow.hasExternalInput = true; } else if (input instanceof DummyInput) { // Dummy inputs have no visual representation, but the information is // still important. activeRow.minHeight = Math.max( activeRow.minHeight, input.getSourceBlock() && input.getSourceBlock()!.isShadow() ? this.constants_.DUMMY_INPUT_SHADOW_MIN_HEIGHT : this.constants_.DUMMY_INPUT_MIN_HEIGHT ); activeRow.hasDummyInput = true; } if (activeRow.align === null) { activeRow.align = input.align; } } /** * Decide whether to start a new row between the two Blockly.Inputs. * * @param input The first input to consider * @param lastInput The input that follows. * @returns True if the next input should be rendered on a new row. */ protected shouldStartNewRow_(input: Input, lastInput?: Input): boolean { // If this is the first input, just add to the existing row. // That row is either empty or has some icons in it. if (!lastInput) { return false; } // A statement input or an input following one always gets a new row. if ( input instanceof StatementInput || lastInput instanceof StatementInput ) { return true; } // Value and dummy inputs get new row if inputs are not inlined. if (input instanceof ValueInput || input instanceof DummyInput) { return !this.isInline; } return false; } /** Add horizontal spacing between and around elements within each row. */ protected addElemSpacing_() { for (let i = 0, row; (row = this.rows[i]); i++) { const oldElems = row.elements; row.elements = []; // No spacing needed before the corner on the top row or the bottom row. if (row.startsWithElemSpacer()) { // There's a spacer before the first element in the row. row.elements.push( new InRowSpacer( this.constants_, this.getInRowSpacing_(null, oldElems[0]) ) ); } if (!oldElems.length) { continue; } for (let e = 0; e < oldElems.length - 1; e++) { row.elements.push(oldElems[e]); const spacing = this.getInRowSpacing_(oldElems[e], oldElems[e + 1]); row.elements.push(new InRowSpacer(this.constants_, spacing)); } row.elements.push(oldElems[oldElems.length - 1]); if (row.endsWithElemSpacer()) { // There's a spacer after the last element in the row. row.elements.push( new InRowSpacer( this.constants_, this.getInRowSpacing_(oldElems[oldElems.length - 1], null) ) ); } } } /** * Calculate the width of a spacer element in a row based on the previous and * next elements in that row. For instance, extra padding is added between * two editable fields. * * @param prev The element before the spacer. * @param next The element after the spacer. * @returns The size of the spacing between the two elements. */ protected getInRowSpacing_( prev: Measurable | null, next: Measurable | null ): number { if (!prev) { // Statement input padding. if (next && Types.isStatementInput(next)) { return this.constants_.STATEMENT_INPUT_PADDING_LEFT; } } // Between inputs and the end of the row. if (prev && Types.isInput(prev) && !next) { if (Types.isExternalInput(prev)) { return this.constants_.NO_PADDING; } else if (Types.isInlineInput(prev)) { return this.constants_.LARGE_PADDING; } else if (Types.isStatementInput(prev)) { return this.constants_.NO_PADDING; } } // Spacing between a square corner and a previous or next connection if (prev && Types.isLeftSquareCorner(prev) && next) { if (Types.isPreviousConnection(next) || Types.isNextConnection(next)) { return next.notchOffset; } } // Spacing between a rounded corner and a previous or next connection. if (prev && Types.isLeftRoundedCorner(prev) && next) { if (Types.isPreviousConnection(next) || Types.isNextConnection(next)) { return next.notchOffset - this.constants_.CORNER_RADIUS; } } return this.constants_.MEDIUM_PADDING; } /** * Figure out where the right edge of the block and right edge of statement * inputs should be placed. */ protected computeBounds_() { let widestStatementRowFields = 0; let blockWidth = 0; let widestRowWithConnectedBlocks = 0; for (let i = 0, row; (row = this.rows[i]); i++) { row.measure(); blockWidth = Math.max(blockWidth, row.width); if (row.hasStatement) { const statementInput = row.getLastInput(); const innerWidth = row.width - (statementInput?.width ?? 0); widestStatementRowFields = Math.max( widestStatementRowFields, innerWidth ); } widestRowWithConnectedBlocks = Math.max( widestRowWithConnectedBlocks, row.widthWithConnectedBlocks ); } this.statementEdge = widestStatementRowFields; this.width = blockWidth; for (let i = 0, row; (row = this.rows[i]); i++) { if (row.hasStatement) { row.statementEdge = this.statementEdge; } } this.widthWithChildren = Math.max(blockWidth, widestRowWithConnectedBlocks); if (this.outputConnection) { this.startX = this.outputConnection.width; this.width += this.outputConnection.width; this.widthWithChildren += this.outputConnection.width; } } /** * Extra spacing may be necessary to make sure that the right sides of all * rows line up. This can only be calculated after a first pass to calculate * the sizes of all rows. */ protected alignRowElements_() { for (let i = 0, row; (row = this.rows[i]); i++) { if (row.hasStatement) { this.alignStatementRow_(row as InputRow); } else { const currentWidth = row.width; const desiredWidth = this.getDesiredRowWidth_(row); const missingSpace = desiredWidth - currentWidth; if (missingSpace > 0) { this.addAlignmentPadding_(row, missingSpace); } if (Types.isTopOrBottomRow(row)) { row.widthWithConnectedBlocks = row.width; } } } } /** * Calculate the desired width of an input row. * * @param _row The input row. * @returns The desired width of the input row. */ protected getDesiredRowWidth_(_row: Row): number { return this.width - this.startX; } /** * Modify the given row to add the given amount of padding around its fields. * The exact location of the padding is based on the alignment property of the * last input in the field. * * @param row The row to add padding to. * @param missingSpace How much padding to add. */ protected addAlignmentPadding_(row: Row, missingSpace: number) { const firstSpacer = row.getFirstSpacer(); const lastSpacer = row.getLastSpacer(); if (row.hasExternalInput || row.hasStatement) { row.widthWithConnectedBlocks += missingSpace; } // Decide where the extra padding goes. if (row.align === Align.LEFT && lastSpacer) { // Add padding to the end of the row. lastSpacer.width += missingSpace; } else if (row.align === Align.CENTRE && firstSpacer && lastSpacer) { // Split the padding between the beginning and end of the row. firstSpacer.width += missingSpace / 2; lastSpacer.width += missingSpace / 2; } else if (row.align === Align.RIGHT && firstSpacer) { // Add padding at the beginning of the row. firstSpacer.width += missingSpace; } else if (lastSpacer) { // Default to left-aligning. lastSpacer.width += missingSpace; } else { return; } row.width += missingSpace; } /** * Align the elements of a statement row based on computed bounds. * Unlike other types of rows, statement rows add space in multiple places. * * @param row The statement row to resize. */ protected alignStatementRow_(row: InputRow) { const statementInput = row.getLastInput(); if (!statementInput) return; let currentWidth = row.width - statementInput.width; let desiredWidth = this.statementEdge; // Add padding before the statement input. const missingSpace = desiredWidth - currentWidth; if (missingSpace > 0) { this.addAlignmentPadding_(row, missingSpace); } // Also widen the statement input to reach to the right side of the // block. Note that this does not add padding. currentWidth = row.width; desiredWidth = this.getDesiredRowWidth_(row); statementInput.width += desiredWidth - currentWidth; statementInput.height = Math.max(statementInput.height, row.height); row.width += desiredWidth - currentWidth; row.widthWithConnectedBlocks = Math.max( row.width, this.statementEdge + row.connectedBlockWidths ); } /** Add spacers between rows and set their sizes. */ protected addRowSpacing_() { const oldRows = this.rows; this.rows = []; for (let r = 0; r < oldRows.length; r++) { this.rows.push(oldRows[r]); if (r !== oldRows.length - 1) { this.rows.push(this.makeSpacerRow_(oldRows[r], oldRows[r + 1])); } } } /** * Create a spacer row to go between prev and next, and set its size. * * @param prev The previous row. * @param next The next row. * @returns The newly created spacer row. */ protected makeSpacerRow_(prev: Row, next: Row): SpacerRow { const height = this.getSpacerRowHeight_(prev, next); const width = this.getSpacerRowWidth_(prev, next); const spacer = new SpacerRow(this.constants_, height, width); if (prev.hasStatement) { spacer.followsStatement = true; } if (next.hasStatement) { spacer.precedesStatement = true; } return spacer; } /** * Calculate the width of a spacer row. * * @param _prev The row before the spacer. * @param _next The row after the spacer. * @returns The desired width of the spacer row between these two rows. */ protected getSpacerRowWidth_(_prev: Row, _next: Row): number { return this.width - this.startX; } /** * Calculate the height of a spacer row. * * @param _prev The row before the spacer. * @param _next The row after the spacer. * @returns The desired height of the spacer row between these two rows. */ protected getSpacerRowHeight_(_prev: Row, _next: Row): number { return this.constants_.MEDIUM_PADDING; } /** * Calculate the centerline of an element in a rendered row. * This base implementation puts the centerline at the middle of the row * vertically, with no special cases. You will likely need extra logic to * handle (at minimum) top and bottom rows. * * @param row The row containing the element. * @param elem The element to place. * @returns The desired centerline of the given element, as an offset from the * top left of the block. */ protected getElemCenterline_(row: Row, elem: Measurable): number { if (Types.isSpacer(elem)) { return row.yPos + elem.height / 2; } if (Types.isBottomRow(row)) { const bottomRow = row as BottomRow; const baseline = bottomRow.yPos + bottomRow.height - bottomRow.descenderHeight; if (Types.isNextConnection(elem)) { return baseline + elem.height / 2; } return baseline - elem.height / 2; } if (Types.isTopRow(row)) { const topRow = row as TopRow; if (Types.isHat(elem)) { return topRow.capline - elem.height / 2; } return topRow.capline + elem.height / 2; } return row.yPos + row.height / 2; } /** * Record final position information on elements on the given row, for use in * drawing. At minimum this records xPos and centerline on each element. * * @param row The row containing the elements. */ protected recordElemPositions_(row: Row) { let xCursor = row.xPos; for (let j = 0, elem; (elem = row.elements[j]); j++) { // Now that row heights are finalized, make spacers use the row height. if (Types.isSpacer(elem)) { elem.height = row.height; } elem.xPos = xCursor; elem.centerline = this.getElemCenterline_(row, elem); xCursor += elem.width; } } /** * 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 finalize_() { // Performance note: this could be combined with the draw pass, if the time // that this takes is excessive. But it shouldn't be, because it only // accesses and sets properties that already exist on the objects. let widestRowWithConnectedBlocks = 0; let yCursor = 0; for (let i = 0, row; (row = this.rows[i]); i++) { row.yPos = yCursor; row.xPos = this.startX; yCursor += row.height; widestRowWithConnectedBlocks = Math.max( widestRowWithConnectedBlocks, row.widthWithConnectedBlocks ); this.recordElemPositions_(row); } if (this.outputConnection && this.block_.nextConnection) { const target = this.block_.nextConnection.targetBlock(); if (target) { // Include width of connected block in value to stack width measurement. widestRowWithConnectedBlocks = Math.max( widestRowWithConnectedBlocks, target.getHeightWidth().width ); } } this.widthWithChildren = widestRowWithConnectedBlocks + this.startX; this.height = yCursor; this.startY = this.topRow.capline; this.bottomRow.baseline = yCursor - this.bottomRow.descenderHeight; } }