Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Google Blockly] Support block limits #57957

Merged
merged 12 commits into from
Apr 12, 2024
131 changes: 131 additions & 0 deletions apps/src/blockly/addons/blockSvgLimitIndicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {BlockSvg} from 'blockly';

/**
* Represents a bubble on a Blockly block, displaying the count of blocks remaining
* based on a limit initially stated in the toolbox XML.
*/
export default class BlockSvgLimitIndicator {
private readonly blockSvg: BlockSvg;
private count: number;
private readonly bubbleSize: number = 18;
private readonly halfBubbleSize: number;
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
private limitGroup: SVGElement | undefined;
private limitRect: SVGElement | undefined;
private limitText: SVGElement | undefined;

/**
* Constructs an SVG group to track a block limit.
* @param {Element} element - The block associated with the limit.
* @param {string} [count] - The initial count to display.
*/
constructor(element: BlockSvg, count: number) {
this.blockSvg = element;
this.count = count;
this.halfBubbleSize = this.bubbleSize / 2;

this.limitGroup = undefined;
this.limitRect = undefined;
this.limitText = undefined;
this.initializeSvgElements();
}

/**
* Updates the displayed count within the limit bubble and adjusts styling based
* on the count.
* @param {number} newCount The new count to display.
*/
public updateCount(newCount: number) {
this.count = newCount;
this.updateTextAndClass();
}

/**
* Updates the text and class of the limit bubble depending on whether the student
* has blocks remaining. If over the limit, displays an exclamation mark and changes
* the bubble and text color.
*/
private updateTextAndClass() {
if (!this.limitText || !this.limitGroup) {
return;
}
if (this.count >= 0) {
this.limitText.textContent = `${this.count}`;
Blockly.utils.dom.removeClass(this.limitGroup, 'overLimit');
Blockly.utils.dom.removeClass(this.limitText, 'overLimit');
} else {
this.limitText.textContent = '!';
Blockly.utils.dom.addClass(this.limitGroup, 'overLimit');
Blockly.utils.dom.addClass(this.limitText, 'overLimit');
}
this.render();
}

/**
* Initializes the SVG elements that make up the limit bubble.
*/
private initializeSvgElements() {
this.limitGroup = Blockly.utils.dom.createSvgElement(
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
'g',
{
class: 'blocklyLimit',
},
this.blockSvg.getSvgRoot()
);

this.limitRect = Blockly.utils.dom.createSvgElement(
'rect',
{
height: this.bubbleSize,
width: this.bubbleSize,
x: -this.halfBubbleSize,
y: -this.halfBubbleSize,
rx: this.halfBubbleSize,
ry: this.halfBubbleSize,
},
this.limitGroup
);

this.limitText = Blockly.utils.dom.createSvgElement(
'text',
{
class: 'blocklyText blocklyLimit',
'dominant-baseline': 'central',
'text-anchor': 'middle',
},
this.limitGroup
);
this.updateTextAndClass();
}

/**
* Renders the limit bubble, adjusting its size and position based on the
* current count text. Called automatically when the instance is created or
* updated.
*/
private render() {
if (!this.limitGroup || !this.limitRect || !this.limitText) {
// If we haven't initialized the children yet, do nothing.
return;
}

const textBBox = (this.limitText as SVGGraphicsElement).getBBox();
const rectWidth = Math.max(
textBBox.width + this.halfBubbleSize,
this.bubbleSize
);
const rectHeight = Math.max(
textBBox.height + this.halfBubbleSize / 2,
this.bubbleSize
);

// Stretch the bubble to to fit longer numbers as text.
this.limitRect.setAttribute('width', `${rectWidth}`);
this.limitRect.setAttribute('height', `${rectHeight}`);
// Center the text in the bubble.
this.limitText.setAttribute('x', `${rectWidth / 2 - this.halfBubbleSize}`);
this.limitText.setAttribute(
'y',
`${Math.ceil(rectHeight / 2) - this.halfBubbleSize}`
);
}
}
9 changes: 9 additions & 0 deletions apps/src/blockly/addons/cdoCss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ export default function initializeCss(blocklyWrapper: BlocklyWrapperType) {
stroke: ${color.brand_secondary_dark};
stroke-width: 3;
}
.blocklyLimit rect {
fill: ${color.brand_accent_default};
}
.blocklyLimit.overLimit rect {
fill: ${color.product_caution_default};
}
.blocklyLimit.overLimit text {
fill: ${color.neutral_dark} !important;
}
`
);
}
72 changes: 68 additions & 4 deletions apps/src/blockly/addons/cdoUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,39 @@ export function bindBrowserEvent(
export function isWorkspaceReadOnly() {
return false; // TODO - used for feedback
}
/**
* Checks if any block type's usage count exceeds its defined limit and returns
* the type of the first block found to exceed.
* @returns {string | null} The type of the first block that exceeds its limit,
* or null if no block exceeds the limit.
*/
export function blockLimitExceeded(): string | null {
const {blockLimitMap, blockCountMap} = Blockly;

// Ensure both maps are defined
if (!blockLimitMap || !blockCountMap) {
return null;
}

// Find the first instance where the limit is exceeded for a block type.
for (const [type, count] of blockCountMap) {
const limit = blockLimitMap.get(type);
if (limit !== undefined && count > limit) {
return type;
}
}

export function blockLimitExceeded() {
return false;
// If no count exceeds the limit, return null.
return null;
}

export function getBlockLimit() {
return 0;
/**
* Retrieves the block limit for a given block type from the block limit map.
* @param {string} type The type of the block to check the limit for.
* @returns {number} The limit for the specified block type, or 0 if not found.
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
*/
export function getBlockLimit(type: string): number {
return Blockly.blockLimitMap?.get(type) ?? 0;
}

/**
Expand Down Expand Up @@ -503,6 +529,44 @@ export function getLevelToolboxBlocks(customCategory: string) {
}
}

/**
* Creates a map of block types and limits, based on limit attributes found
* in the block XML for the current toolbox.
* @returns {Map<string, number>} A map of block limits
*/
export function createBlockLimitMap() {
const parser = new DOMParser();
// This method only works for string toolboxes.
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
if (!Blockly.toolboxBlocks || typeof Blockly.toolboxBlocks !== 'string') {
return;
}

const xmlDoc = parser.parseFromString(Blockly.toolboxBlocks, 'text/xml');
// Define blockLimitMap
const blockLimitMap = new Map<string, number>();

// Select all block elements and convert NodeList to array
const blocks = Array.from(xmlDoc.querySelectorAll('block'));

// Iterate over each block element using forEach
blocks.forEach(block => {
// Check if the block has a limit attribute
const limitAttribute = block.getAttribute('limit');
mikeharv marked this conversation as resolved.
Show resolved Hide resolved

// Directly parse the attribute. Template string is used to handle null values.
const limit = parseInt(`${limitAttribute}`);

if (!isNaN(limit)) {
// Extract type and add to blockLimitMap
const type = block.getAttribute('type');
if (type !== null) {
blockLimitMap.set(type, limit);
}
}
});
return blockLimitMap;
}

/**
* Simplifies the state of blocks for a flyout by removing properties like x/y and id.
* Also replaces variable IDs with variable names derived from the serialied variable map.
Expand Down
86 changes: 83 additions & 3 deletions apps/src/blockly/eventHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
import {handleWorkspaceResizeOrScroll} from '@cdo/apps/code-studio/callouts';
import {BLOCK_TYPES} from './constants';
import {Abstract} from 'blockly/core/events/events_abstract';
import {BlockChange} from 'blockly/core/events/events_block_change';
import {BlockMove} from 'blockly/core/events/events_block_move';
import {BlockCreate} from 'blockly/core/events/events_block_create';
import {Block, WorkspaceSvg} from 'blockly';
import {ExtendedBlockSvg, ExtendedWorkspaceSvg} from './types';
import BlockSvgLimitIndicator from './addons/blockSvgLimitIndicator';
import type {
BlockChange,
BlockMove,
BlockCreate,
ThemeChange,
} from 'blockly/core/events/events';

// A custom version of Blockly's Events.disableOrphans. This makes a couple
// changes to the original function.
Expand Down Expand Up @@ -117,3 +122,78 @@ export function reflowToolbox() {
modalWorkspace?.getFlyout()?.reflow();
}
}
export function updateBlockLimits(event: Abstract) {
// This check is to update bubbles that show block limits whenever
// blocks on the main workspace are updated.
if (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We could add an isRelevantEvent helper for this.

if (!isRelevantEvent(event)) {
  return;
}

Copy link
Contributor Author

@mikeharv mikeharv Apr 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would that help with readability? Could also do something like
![...expectedEventTypes].includes(event.type)

event.type !== Blockly.Events.BLOCK_CHANGE &&
event.type !== Blockly.Events.BLOCK_MOVE &&
event.type !== Blockly.Events.BLOCK_CREATE &&
event.type !== Blockly.Events.THEME_CHANGE
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
) {
return;
}
const blockEvent = event as
| BlockChange
| BlockMove
| BlockCreate
| ThemeChange;
const blockLimitMap = Blockly.blockLimitMap;

if (!blockEvent.workspaceId || !blockLimitMap || !(blockLimitMap?.size > 0)) {
return;
}
const eventWorkspace = Blockly.Workspace.getById(
blockEvent.workspaceId
) as ExtendedWorkspaceSvg;
const allWorkspaceBlocks = eventWorkspace?.getAllBlocks();
if (!allWorkspaceBlocks) {
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
return;
}
// Define a Map to store block counts for each type
const blockCountMap = new Map<string, number>();
Blockly.blockCountMap = blockCountMap;
// Initialize block counts based on blockLimitMap
blockLimitMap.forEach((_, type) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to initialize all of these to zero?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to make sure that both maps include the same keys (block types). Initializing to 0 ensures that blocks that the student hasn't used yet are still list in the count map.

blockCountMap.set(type, 0);
});

// Count the enabled blocks of each type
allWorkspaceBlocks
.filter(block => blockLimitMap.has(block.type) && block.isEnabled())
.forEach(block => {
const type = block.type;
if (blockCountMap.has(type)) {
const currentCount = blockCountMap.get(type) || 0;
blockCountMap.set(type, currentCount + 1);
}
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
});

const flyout = eventWorkspace.getFlyout();
if (!flyout) {
return;
}
// Get all blocks from the flyout
const flyoutBlocks = flyout
.getWorkspace()
.getTopBlocks() as ExtendedBlockSvg[];

// Get the flyout blocks that have limits
const limitedFlyoutBlocks = flyoutBlocks.filter(block => {
return blockLimitMap.has(block.type);
});

limitedFlyoutBlocks.forEach(flyoutBlock => {
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
const blockLimitCount = blockLimitMap.get(flyoutBlock.type) as number;
const blockUsedCount = blockCountMap.get(flyoutBlock.type) || 0;
const remainingCount = blockLimitCount - blockUsedCount;
if (flyoutBlock.blockSvgLimitIndicator) {
flyoutBlock.blockSvgLimitIndicator.updateCount(remainingCount);
} else {
flyoutBlock.blockSvgLimitIndicator = new BlockSvgLimitIndicator(
flyoutBlock,
remainingCount
);
}
});
}
5 changes: 5 additions & 0 deletions apps/src/blockly/googleBlocklyWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
adjustCalloutsOnViewportChange,
disableOrphans,
reflowToolbox,
updateBlockLimits,
} from './eventHandlers';
import {initializeScrollbarPair} from './addons/cdoScrollbar';
import {getStore} from '@cdo/apps/redux';
Expand Down Expand Up @@ -683,6 +684,7 @@ function initializeBlocklyWrapper(blocklyInstance: GoogleBlocklyInstance) {
blocklyWrapper.isToolboxMode =
optOptionsExtended.editBlocks === 'toolbox_blocks';
blocklyWrapper.toolboxBlocks = options.toolbox;
blocklyWrapper.blockLimitMap = cdoUtils.createBlockLimitMap();
const workspace = blocklyWrapper.blockly_.inject(
container,
options
Expand Down Expand Up @@ -715,6 +717,9 @@ function initializeBlocklyWrapper(blocklyInstance: GoogleBlocklyInstance) {
if (!blocklyWrapper.isStartMode && !optOptionsExtended.isBlockEditMode) {
workspace.addChangeListener(disableOrphans);
}
if (blocklyWrapper.blockLimitMap && blocklyWrapper.blockLimitMap.size > 0) {
workspace.addChangeListener(updateBlockLimits);
}

// When either the main workspace or the toolbox workspace viewport
// changes, adjust any callouts so they stay pointing to the appropriate
Expand Down
4 changes: 4 additions & 0 deletions apps/src/blockly/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import CdoFieldVariable from './addons/cdoFieldVariable';
import CdoFieldBehaviorPicker from './addons/cdoFieldBehaviorPicker';
import CdoFieldAngleDropdown from './addons/cdoFieldAngleDropdown';
import CdoFieldAngleTextInput from './addons/cdoFieldAngleTextInput';
import BlockSvgLimitIndicator from './addons/blockSvgLimitIndicator';

export interface BlockDefinition {
category: string;
Expand Down Expand Up @@ -76,6 +77,8 @@ type GoogleBlocklyType = typeof GoogleBlockly;

// Type for the Blockly instance created and modified by googleBlocklyWrapper.
export interface BlocklyWrapperType extends GoogleBlocklyType {
blockCountMap: Map<string, number> | undefined;
blockLimitMap: Map<string, number> | undefined;
readOnly: boolean;
grayOutUndeletableBlocks: boolean;
topLevelProcedureAutopopulate: boolean;
Expand Down Expand Up @@ -185,6 +188,7 @@ export interface ExtendedBlockSvg extends BlockSvg {
thumbnailSize?: number;
// used for function blocks
functionalSvg_?: BlockSvgFrame;
blockSvgLimitIndicator?: BlockSvgLimitIndicator;
workspace: ExtendedWorkspaceSvg;
}

Expand Down