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
144 changes: 144 additions & 0 deletions apps/src/blockly/addons/blockSvgLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {BlockSvg} from 'blockly';

const BUBBLE_SIZE = 18;
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved
const HALF_BUBBLE_SIZE = BUBBLE_SIZE / 2;
/**
* 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 BlockSvgLimit {
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved
protected element_: BlockSvg;
protected count: number;
protected className: string;
protected limitGroup_: SVGElement | undefined;
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved
protected limitRect_: SVGElement | undefined;
protected 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) {
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved
this.element_ = element;
this.count = count;
this.className = 'blocklyLimit';

this.limitGroup_ = undefined;
this.limitRect_ = undefined;
this.limitText_ = undefined;
this.element_;
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
this.initChildren();
}

/**
* Updates the displayed count within the limit bubble and adjusts styling based
* on the count.
* @param {number} newCount The new count to display.
*/
updateCount(newCount: number) {
// Update the count and text content
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
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.
*/
updateTextAndClass() {
if (!this.limitText_ || !this.limitGroup_) {
return;
}
if (this.count >= 0) {
this.limitText_.textContent = `${this.count}`;
Blockly.utils.dom.removeClass(this.limitGroup_, 'overLimit');
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved
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();
}

/**
* Disposes of the limit group and its children, cleaning up all references.
*/
dispose() {
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved
this.limitGroup_?.remove();
this.limitGroup_ = undefined;
this.limitRect_ = undefined;
this.limitText_ = undefined;
}

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

this.limitRect_ = Blockly.utils.dom.createSvgElement(
'rect',
{
height: BUBBLE_SIZE,
width: BUBBLE_SIZE,
x: -HALF_BUBBLE_SIZE,
y: -HALF_BUBBLE_SIZE,
rx: HALF_BUBBLE_SIZE,
ry: HALF_BUBBLE_SIZE,
},
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.
*/
render() {
if (!this.limitGroup_ || !this.limitRect_ || !this.limitText_) {
// If we haven't initialized the children yet, do nothing.
return;
}

const svgGroup = this.element_.getSvgRoot();
svgGroup.append(this.limitGroup_);
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved

const textBBox = (this.limitText_ as SVGGraphicsElement).getBBox();
const rectWidth = Math.max(textBBox.width + HALF_BUBBLE_SIZE, BUBBLE_SIZE);
const rectHeight = Math.max(
textBBox.height + HALF_BUBBLE_SIZE / 2,
BUBBLE_SIZE
);

// 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 * 0.5 - HALF_BUBBLE_SIZE}`);
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
this.limitText_.setAttribute(
'y',
`${Math.ceil(rectHeight * 0.5) - HALF_BUBBLE_SIZE}`
);
}
}
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;
}
`
);
}
81 changes: 78 additions & 3 deletions apps/src/blockly/addons/cdoUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,48 @@ export function isWorkspaceReadOnly() {
return false; // TODO - used for feedback
}

export function blockLimitExceeded() {
return false;
/**
* 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 | undefined} The type of the first block that exceeds its limit,
* or undefined if no block exceeds the limit.
*/
export function blockLimitExceeded(): string | undefined {
const blockLimitMap = Blockly.blockLimitMap;
const blockCountMap = Blockly.blockCountMap;
mikeharv marked this conversation as resolved.
Show resolved Hide resolved

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

// Iterate over the block count map
for (const [type, count] of blockCountMap) {
const limit = blockLimitMap.get(type);
// If a limit is defined and the count exceeds this limit, return the type
if (limit !== undefined && count > limit) {
return type;
}
}

// If no count exceeds the limit, return undefined
return undefined;
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved
}

export function getBlockLimit() {
/**
* 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 {
const blockLimitMap = Blockly.blockLimitMap;

// Check if the block limit map is defined and has the requested type
if (blockLimitMap && blockLimitMap.has(type)) {
return blockLimitMap.get(type) || 0;
}

// Return undefined if the map is not defined or the type is not found
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
return 0;
}

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

/**
* Creates a map of block types and limits, based on limit attribtues found
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
* in the block XML for the current toolbox.
* @returns {Map} A map of block limits
mikeharv marked this conversation as resolved.
Show resolved Hide resolved
*/
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
if (limitAttribute !== null) {
// Check if the limit attribute is a valid number
const limit = parseInt(limitAttribute);
if (!isNaN(limit)) {
// Extract type and add to blockLimitMap
const type = block.getAttribute('type');
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved
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
75 changes: 75 additions & 0 deletions apps/src/blockly/eventHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ 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';
ebeastlake marked this conversation as resolved.
Show resolved Hide resolved
import {Block, WorkspaceSvg} from 'blockly';
import {ExtendedBlockSvg, ExtendedWorkspaceSvg} from './types';
import BlockSvgLimit from './addons/blockSvgLimit';
import {ThemeChange} from 'blockly/core/events/events_theme_change';

// A custom version of Blockly's Events.disableOrphans. This makes a couple
// changes to the original function.
Expand Down Expand Up @@ -117,3 +120,75 @@ 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.blockLimit_) {
flyoutBlock.blockLimit_.updateCount(remainingCount);
} else {
flyoutBlock.blockLimit_ = new BlockSvgLimit(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 BlockSvgLimit from './addons/blockSvgLimit';

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;
blockLimit_?: BlockSvgLimit;
workspace: ExtendedWorkspaceSvg;
}

Expand Down