release: Merge branch 'develop' into rc/v11.2.2

This commit is contained in:
Aaron Dodson
2025-03-20 11:12:39 -07:00
36 changed files with 1209 additions and 777 deletions

View File

@@ -42,7 +42,7 @@ jobs:
path: _deploy/
- name: Deploy to App Engine
uses: google-github-actions/deploy-appengine@v2.1.4
uses: google-github-actions/deploy-appengine@v2.1.5
# For parameters see:
# https://github.com/google-github-actions/deploy-appengine#inputs
with:

View File

@@ -16,6 +16,7 @@ jobs:
requested-reviewer:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Assign requested reviewer

View File

@@ -8,6 +8,7 @@ jobs:
label:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: bcoe/conventional-release-labels@v1

View File

@@ -1046,22 +1046,19 @@ blocks['lists_split'] = {
/**
* Returns the state of this block as a JSON serializable object.
* This block does not need to serialize any specific state as it is already
* encoded in the dropdown values, but must have an implementation to avoid
* the backward compatible XML mutations being serialized.
*
* @returns The state of this block.
*/
saveExtraState: function (this: SplitBlock): null {
return null;
saveExtraState: function (this: SplitBlock): {mode: string} {
return {'mode': this.getFieldValue('MODE')};
},
/**
* Applies the given state to this block.
* No extra state is needed or expected as it is already encoded in the
* dropdown values.
*/
loadExtraState: function (this: SplitBlock) {},
loadExtraState: function (this: SplitBlock, state: {mode: string}) {
this.updateType_(state['mode']);
},
};
// Register provided blocks.

View File

@@ -176,7 +176,7 @@ export function disconnectUiEffect(block: BlockSvg) {
}
// Start the animation.
wobblingBlock = block;
disconnectUiStep(block, magnitude, new Date());
disconnectUiStep(block, magnitude, new Date(), 0);
}
/**
@@ -184,22 +184,30 @@ export function disconnectUiEffect(block: BlockSvg) {
*
* @param block Block to animate.
* @param magnitude Maximum degrees skew (reversed for RTL).
* @param start Date of animation's start.
* @param start Date of animation's start for deciding when to stop.
* @param step Which step of the animation we're on.
*/
function disconnectUiStep(block: BlockSvg, magnitude: number, start: Date) {
function disconnectUiStep(
block: BlockSvg,
magnitude: number,
start: Date,
step: number,
) {
const DURATION = 200; // Milliseconds.
const WIGGLES = 3; // Half oscillations.
const ms = new Date().getTime() - start.getTime();
const percent = ms / DURATION;
const WIGGLES = [0.66, 1, 0.66, 0, -0.66, -1, -0.66, 0]; // Single cycle
let skew = '';
if (percent <= 1) {
const val = Math.round(
Math.sin(percent * Math.PI * WIGGLES) * (1 - percent) * magnitude,
);
if (start.getTime() + DURATION > new Date().getTime()) {
const val = Math.round(WIGGLES[step % WIGGLES.length] * magnitude);
skew = `skewX(${val})`;
disconnectPid = setTimeout(disconnectUiStep, 10, block, magnitude, start);
disconnectPid = setTimeout(
disconnectUiStep,
15,
block,
magnitude,
start,
step + 1,
);
}
block

View File

@@ -243,7 +243,7 @@ export class BlockSvg
*
* @returns #RRGGBB string.
*/
getColourSecondary(): string | undefined {
getColourSecondary(): string {
return this.style.colourSecondary;
}
@@ -252,7 +252,7 @@ export class BlockSvg
*
* @returns #RRGGBB string.
*/
getColourTertiary(): string | undefined {
getColourTertiary(): string {
return this.style.colourTertiary;
}
@@ -1172,6 +1172,15 @@ export class BlockSvg
}
}
/**
* Returns the BlockStyle object used to style this block.
*
* @returns This block's style object.
*/
getStyle(): BlockStyle {
return this.style;
}
/**
* Move this block to the front of the visible workspace.
* <g> tags do not respect z-index so SVG renders them in the

View File

@@ -16,7 +16,7 @@ import {Bubble} from './bubble.js';
* A bubble that displays non-editable text. Used by the warning icon.
*/
export class TextBubble extends Bubble {
private paragraph: SVGTextElement;
private paragraph: SVGGElement;
constructor(
private text: string,
@@ -48,43 +48,52 @@ export class TextBubble extends Bubble {
*/
private stringToSvg(text: string, container: SVGGElement) {
const paragraph = this.createParagraph(container);
const spans = this.createSpans(paragraph, text);
const fragments = this.createTextFragments(paragraph, text);
if (this.workspace.RTL)
this.rightAlignSpans(paragraph.getBBox().width, spans);
this.rightAlignTextFragments(paragraph.getBBox().width, fragments);
return paragraph;
}
/** Creates the paragraph container for this bubble's view's spans. */
private createParagraph(container: SVGGElement): SVGTextElement {
/** Creates the paragraph container for this bubble's view's text fragments. */
private createParagraph(container: SVGGElement): SVGGElement {
return dom.createSvgElement(
Svg.TEXT,
Svg.G,
{
'class': 'blocklyText blocklyBubbleText blocklyNoPointerEvents',
'y': Bubble.BORDER_WIDTH,
'transform': `translate(0,${Bubble.BORDER_WIDTH})`,
'style': `direction: ${this.workspace.RTL ? 'rtl' : 'ltr'}`,
},
container,
);
}
/** Creates the spans visualizing the text of this bubble. */
private createSpans(parent: SVGTextElement, text: string): SVGTSpanElement[] {
/** Creates the text fragments visualizing the text of this bubble. */
private createTextFragments(
parent: SVGGElement,
text: string,
): SVGTextElement[] {
let lineNum = 1;
return text.split('\n').map((line) => {
const tspan = dom.createSvgElement(
Svg.TSPAN,
{'dy': '1em', 'x': Bubble.BORDER_WIDTH},
const fragment = dom.createSvgElement(
Svg.TEXT,
{'y': `${lineNum}em`, 'x': Bubble.BORDER_WIDTH},
parent,
);
const textNode = document.createTextNode(line);
tspan.appendChild(textNode);
return tspan;
fragment.appendChild(textNode);
lineNum += 1;
return fragment;
});
}
/** Right aligns the given spans. */
private rightAlignSpans(maxWidth: number, spans: SVGTSpanElement[]) {
for (const span of spans) {
span.setAttribute('text-anchor', 'end');
span.setAttribute('x', `${maxWidth + Bubble.BORDER_WIDTH}`);
/** Right aligns the given text fragments. */
private rightAlignTextFragments(
maxWidth: number,
fragments: SVGTextElement[],
) {
for (const text of fragments) {
text.setAttribute('text-anchor', 'start');
text.setAttribute('x', `${maxWidth + Bubble.BORDER_WIDTH}`);
}
}

View File

@@ -83,7 +83,9 @@ function pasteFromData<T extends ICopyData>(
workspace: WorkspaceSvg,
coordinate?: Coordinate,
): ICopyable<T> | null {
workspace = workspace.getRootWorkspace() ?? workspace;
workspace = workspace.isMutator
? workspace
: (workspace.getRootWorkspace() ?? workspace);
return (globalRegistry
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;

View File

@@ -208,8 +208,14 @@ export class RenderedWorkspaceComment
private startGesture(e: PointerEvent) {
const gesture = this.workspace.getGesture(e);
if (gesture) {
gesture.handleCommentStart(e, this);
this.workspace.getLayerManager()?.append(this, layers.BLOCK);
if (browserEvents.isTargetInput(e)) {
// If the text area was the focus, don't allow this event to bubble up
// and steal focus away from the editor/comment.
e.stopPropagation();
} else {
gesture.handleCommentStart(e, this);
this.workspace.getLayerManager()?.append(this, layers.BLOCK);
}
common.setSelected(this);
}
}

View File

@@ -611,7 +611,9 @@ export function registerCommentDuplicate() {
export function registerCommentCreate() {
const createOption: RegistryItem = {
displayText: () => Msg['ADD_COMMENT'],
preconditionFn: () => 'enabled',
preconditionFn: (scope: Scope) => {
return scope.workspace?.isMutator ? 'hidden' : 'enabled';
},
callback: (scope: Scope, e: PointerEvent) => {
const workspace = scope.workspace;
if (!workspace) return;

View File

@@ -353,6 +353,7 @@ input[type=number] {
.blocklyScrollbarBackground {
opacity: 0;
pointer-events: none;
}
.blocklyScrollbarHandle {

View File

@@ -29,6 +29,7 @@ import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import * as utilsString from './utils/string.js';
import * as style from './utils/style.js';
import {Svg} from './utils/svg.js';
/**
@@ -290,7 +291,7 @@ export class FieldDropdown extends Field<string> {
if (this.getConstants()!.FIELD_DROPDOWN_COLOURED_DIV) {
const primaryColour = block.getColour();
const borderColour = (this.sourceBlock_ as BlockSvg).style.colourTertiary;
const borderColour = (this.sourceBlock_ as BlockSvg).getColourTertiary();
dropDownDiv.setColour(primaryColour, borderColour);
}
@@ -303,6 +304,11 @@ export class FieldDropdown extends Field<string> {
if (this.selectedMenuItem) {
this.menu_!.setHighlighted(this.selectedMenuItem);
style.scrollIntoContainerView(
this.selectedMenuItem.getElement()!,
dropDownDiv.getContentDiv(),
true,
);
}
this.applyColour();
@@ -461,21 +467,21 @@ export class FieldDropdown extends Field<string> {
* Updates the dropdown arrow to match the colour/style of the block.
*/
override applyColour() {
const style = (this.sourceBlock_ as BlockSvg).style;
const sourceBlock = this.sourceBlock_ as BlockSvg;
if (this.borderRect_) {
this.borderRect_.setAttribute('stroke', style.colourTertiary);
this.borderRect_.setAttribute('stroke', sourceBlock.getColourTertiary());
if (this.menu_) {
this.borderRect_.setAttribute('fill', style.colourTertiary);
this.borderRect_.setAttribute('fill', sourceBlock.getColourTertiary());
} else {
this.borderRect_.setAttribute('fill', 'transparent');
}
}
// Update arrow's colour.
if (this.sourceBlock_ && this.arrow) {
if (this.sourceBlock_.isShadow()) {
this.arrow.style.fill = style.colourSecondary;
if (sourceBlock && this.arrow) {
if (sourceBlock.isShadow()) {
this.arrow.style.fill = sourceBlock.getColourSecondary();
} else {
this.arrow.style.fill = style.colourPrimary;
this.arrow.style.fill = sourceBlock.getColour();
}
}
}

View File

@@ -226,7 +226,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (!this.isFullBlockField() && this.borderRect_) {
this.borderRect_!.style.display = 'block';
this.borderRect_.setAttribute('stroke', block.style.colourTertiary);
this.borderRect_.setAttribute('stroke', block.getColourTertiary());
} else {
this.borderRect_!.style.display = 'none';
// In general, do *not* let fields control the color of blocks. Having the
@@ -429,8 +429,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
borderRadius = (bBox.bottom - bBox.top) / 2 + 'px';
// Pull stroke colour from the existing shadow block
const strokeColour = block.getParent()
? (block.getParent() as BlockSvg).style.colourTertiary
: (this.sourceBlock_ as BlockSvg).style.colourTertiary;
? (block.getParent() as BlockSvg).getColourTertiary()
: (this.sourceBlock_ as BlockSvg).getColourTertiary();
htmlInput.style.border = 1 * scale + 'px solid ' + strokeColour;
div!.style.borderRadius = borderRadius;
div!.style.transition = 'box-shadow 0.25s ease 0s';

View File

@@ -126,7 +126,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
override applyColour(): void {
super.applyColour();
const colour = (this.sourceBlock as BlockSvg).style.colourPrimary;
const colour = (this.sourceBlock as BlockSvg).getColour();
this.textInputBubble?.setColour(colour);
}

View File

@@ -136,7 +136,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
override applyColour(): void {
super.applyColour();
this.miniWorkspaceBubble?.setColour(this.sourceBlock.style.colourPrimary);
this.miniWorkspaceBubble?.setColour(this.sourceBlock.getColour());
this.miniWorkspaceBubble?.updateBlockStyles();
}

View File

@@ -108,7 +108,7 @@ export class WarningIcon extends Icon implements IHasBubble {
override applyColour(): void {
super.applyColour();
this.textBubble?.setColour(this.sourceBlock.style.colourPrimary);
this.textBubble?.setColour(this.sourceBlock.getColour());
}
override updateCollapsed(): void {

View File

@@ -31,8 +31,8 @@ export class Menu {
private readonly menuItems: MenuItem[] = [];
/**
* Coordinates of the mousedown event that caused this menu to open. Used to
* prevent the consequent mouseup event due to a simple click from
* Coordinates of the pointerdown event that caused this menu to open. Used to
* prevent the consequent pointerup event due to a simple click from
* activating a menu item immediately.
*/
openingCoords: Coordinate | null = null;
@@ -43,17 +43,17 @@ export class Menu {
*/
private highlightedItem: MenuItem | null = null;
/** Mouse over event data. */
private mouseOverHandler: browserEvents.Data | null = null;
/** Pointer over event data. */
private pointerMoveHandler: browserEvents.Data | null = null;
/** Click event data. */
private clickHandler: browserEvents.Data | null = null;
/** Mouse enter event data. */
private mouseEnterHandler: browserEvents.Data | null = null;
/** Pointer enter event data. */
private pointerEnterHandler: browserEvents.Data | null = null;
/** Mouse leave event data. */
private mouseLeaveHandler: browserEvents.Data | null = null;
/** Pointer leave event data. */
private pointerLeaveHandler: browserEvents.Data | null = null;
/** Key down event data. */
private onKeyDownHandler: browserEvents.Data | null = null;
@@ -99,11 +99,11 @@ export class Menu {
}
// Add event handlers.
this.mouseOverHandler = browserEvents.conditionalBind(
this.pointerMoveHandler = browserEvents.conditionalBind(
element,
'pointerover',
'pointermove',
this,
this.handleMouseOver,
this.handlePointerMove,
true,
);
this.clickHandler = browserEvents.conditionalBind(
@@ -113,18 +113,18 @@ export class Menu {
this.handleClick,
true,
);
this.mouseEnterHandler = browserEvents.conditionalBind(
this.pointerEnterHandler = browserEvents.conditionalBind(
element,
'pointerenter',
this,
this.handleMouseEnter,
this.handlePointerEnter,
true,
);
this.mouseLeaveHandler = browserEvents.conditionalBind(
this.pointerLeaveHandler = browserEvents.conditionalBind(
element,
'pointerleave',
this,
this.handleMouseLeave,
this.handlePointerLeave,
true,
);
this.onKeyDownHandler = browserEvents.conditionalBind(
@@ -183,21 +183,21 @@ export class Menu {
/** Dispose of this menu. */
dispose() {
// Remove event handlers.
if (this.mouseOverHandler) {
browserEvents.unbind(this.mouseOverHandler);
this.mouseOverHandler = null;
if (this.pointerMoveHandler) {
browserEvents.unbind(this.pointerMoveHandler);
this.pointerMoveHandler = null;
}
if (this.clickHandler) {
browserEvents.unbind(this.clickHandler);
this.clickHandler = null;
}
if (this.mouseEnterHandler) {
browserEvents.unbind(this.mouseEnterHandler);
this.mouseEnterHandler = null;
if (this.pointerEnterHandler) {
browserEvents.unbind(this.pointerEnterHandler);
this.pointerEnterHandler = null;
}
if (this.mouseLeaveHandler) {
browserEvents.unbind(this.mouseLeaveHandler);
this.mouseLeaveHandler = null;
if (this.pointerLeaveHandler) {
browserEvents.unbind(this.pointerLeaveHandler);
this.pointerLeaveHandler = null;
}
if (this.onKeyDownHandler) {
browserEvents.unbind(this.onKeyDownHandler);
@@ -260,14 +260,13 @@ export class Menu {
this.highlightedItem = item;
// Bring the highlighted item into view. This has no effect if the menu is
// not scrollable.
const el = this.getElement();
if (el) {
aria.setState(el, aria.State.ACTIVEDESCENDANT, item.getId());
}
item.getElement()?.scrollIntoView({
block: 'nearest',
inline: 'start',
});
const menuElement = this.getElement();
const scrollingParent = menuElement?.parentElement;
const menuItemElement = item.getElement();
if (!scrollingParent || !menuItemElement) return;
style.scrollIntoContainerView(menuItemElement, scrollingParent);
aria.setState(menuElement, aria.State.ACTIVEDESCENDANT, item.getId());
}
}
@@ -326,14 +325,26 @@ export class Menu {
}
}
// Mouse events.
// Pointer events.
/**
* Handles mouseover events. Highlight menuitems as the user hovers over them.
* Handles pointermove events. Highlight menu items as the user hovers over
* them.
*
* @param e Mouse event to handle.
* @param e Pointer event to handle.
*/
private handleMouseOver(e: PointerEvent) {
private handlePointerMove(e: PointerEvent) {
// Check whether the pointer actually did move. Move events are triggered if
// the element underneath the pointer moves, even if the pointer itself has
// remained stationary. In the case where the pointer is hovering over
// the menu but the user is navigating through the list of items via the
// keyboard and causing items off the end of the menu to scroll into view,
// a pointermove event would be triggered due to the pointer now being over
// a new child, but we don't want to highlight the item that's now under the
// pointer.
const delta = Math.max(Math.abs(e.movementX), Math.abs(e.movementY));
if (delta === 0) return;
const menuItem = this.getMenuItem(e.target as Element);
if (menuItem) {
@@ -359,11 +370,11 @@ export class Menu {
if (oldCoords && typeof e.clientX === 'number') {
const newCoords = new Coordinate(e.clientX, e.clientY);
if (Coordinate.distance(oldCoords, newCoords) < 1) {
// This menu was opened by a mousedown and we're handling the consequent
// click event. The coords haven't changed, meaning this was the same
// opening event. Don't do the usual behavior because the menu just
// popped up under the mouse and the user didn't mean to activate this
// item.
// This menu was opened by a pointerdown and we're handling the
// consequent click event. The coords haven't changed, meaning this was
// the same opening event. Don't do the usual behavior because the menu
// just popped up under the pointer and the user didn't mean to activate
// this item.
return;
}
}
@@ -375,22 +386,21 @@ export class Menu {
}
/**
* Handles mouse enter events. Focus the element.
* Handles pointer enter events. Focus the element.
*
* @param _e Mouse event to handle.
* @param _e Pointer event to handle.
*/
private handleMouseEnter(_e: PointerEvent) {
private handlePointerEnter(_e: PointerEvent) {
this.focus();
}
/**
* Handles mouse leave events. Blur and clear highlight.
* Handles pointer leave events by clearing the active highlight.
*
* @param _e Mouse event to handle.
* @param _e Pointer event to handle.
*/
private handleMouseLeave(_e: PointerEvent) {
private handlePointerLeave(_e: PointerEvent) {
if (this.getElement()) {
this.blur();
this.setHighlighted(null);
}
}

View File

@@ -533,6 +533,21 @@ export class RenderedConnection extends Connection {
childBlock.updateDisabled();
childBlock.queueRender();
// If either block being connected was selected, visually un- and reselect
// it. This has the effect of moving the selection path to the end of the
// list of child nodes in the DOM. Since SVG z-order is determined by node
// order in the DOM, this works around an issue where the selection outline
// path could be partially obscured by a new block inserted after it in the
// DOM.
const selection = common.getSelected();
const selectedBlock =
(selection === parentBlock && parentBlock) ||
(selection === childBlock && childBlock);
if (selectedBlock) {
selectedBlock.removeSelect();
selectedBlock.addSelect();
}
// The input the child block is connected to (if any).
const parentInput = parentBlock.getInputWithBlock(childBlock);
if (parentInput) {

View File

@@ -844,11 +844,11 @@ export class ConstantProvider extends BaseConstantProvider {
`}`,
// Widget and Dropdown Div
`${selector}.blocklyWidgetDiv .goog-menuitem,`,
`${selector}.blocklyDropDownDiv .goog-menuitem {`,
`${selector}.blocklyWidgetDiv .blocklyMenuItem,`,
`${selector}.blocklyDropDownDiv .blocklyMenuItem {`,
`font-family: ${this.FIELD_TEXT_FONTFAMILY};`,
`}`,
`${selector}.blocklyDropDownDiv .goog-menuitem-content {`,
`${selector}.blocklyDropDownDiv .blocklyMenuItemContent {`,
`color: #fff;`,
`}`,

View File

@@ -68,7 +68,7 @@ export class PathObject extends BasePathObject {
// Set shadow stroke colour.
const parent = block.getParent();
if (block.isShadow() && parent) {
this.svgPath.setAttribute('stroke', parent.style.colourTertiary);
this.svgPath.setAttribute('stroke', parent.getColourTertiary());
}
// Apply colour to outlines.

View File

@@ -18,7 +18,7 @@ import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {Coordinate} from './utils/coordinate.js';
import {KeyCodes} from './utils/keycodes.js';
import {Rect} from './utils/rect.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import {WorkspaceSvg} from './workspace_svg.js';
/**
* Object holding the names of the default shortcut items.
@@ -131,7 +131,10 @@ export function registerCopy() {
const selected = common.getSelected();
if (!selected || !isCopyable(selected)) return false;
copyData = selected.toCopyData();
copyWorkspace = workspace;
copyWorkspace =
selected.workspace instanceof WorkspaceSvg
? selected.workspace
: workspace;
copyCoords = isDraggable(selected)
? selected.getRelativeToSurfaceXY()
: null;

View File

@@ -395,6 +395,8 @@ export class Trashcan
'transform',
'translate(' + this.left + ',' + this.top + ')',
);
this.flyout?.position();
}
/**

View File

@@ -7,7 +7,6 @@
// Former goog.module ID: Blockly.utils.style
import {Coordinate} from './coordinate.js';
import * as deprecation from './deprecation.js';
import {Rect} from './rect.js';
import {Size} from './size.js';
@@ -59,7 +58,6 @@ function getSizeInternal(element: Element): Size {
* @returns Object with width/height properties.
*/
function getSizeWithDisplay(element: Element): Size {
deprecation.warn(`Blockly.utils.style.getSizeWithDisplay()`, 'v11.2', 'v13');
const offsetWidth = (element as HTMLElement).offsetWidth;
const offsetHeight = (element as HTMLElement).offsetHeight;
return new Size(offsetWidth, offsetHeight);
@@ -132,7 +130,6 @@ export function getViewportPageOffset(): Coordinate {
* @returns The computed border widths.
*/
export function getBorderBox(element: Element): Rect {
deprecation.warn(`Blockly.utils.style.getBorderBox()`, 'v11.2', 'v13');
const left = parseFloat(getComputedStyle(element, 'borderLeftWidth'));
const right = parseFloat(getComputedStyle(element, 'borderRightWidth'));
const top = parseFloat(getComputedStyle(element, 'borderTopWidth'));
@@ -159,12 +156,6 @@ export function scrollIntoContainerView(
container: Element,
opt_center?: boolean,
) {
deprecation.warn(
`Blockly.utils.style.scrollIntoContainerView()`,
'v11.2',
'v13',
'the native Element.scrollIntoView()',
);
const offset = getContainerOffsetToScrollInto(element, container, opt_center);
container.scrollLeft = offset.x;
container.scrollTop = offset.y;
@@ -189,11 +180,6 @@ export function getContainerOffsetToScrollInto(
container: Element,
opt_center?: boolean,
): Coordinate {
deprecation.warn(
`Blockly.utils.style.getContainerOffsetToScrollInto()`,
'v11.2',
'v13',
);
// Absolute position of the element's border's top left corner.
const elementPos = getPageOffset(element);
// Absolute position of the container's border's top left corner.

View File

@@ -46,7 +46,9 @@ export function controls_if(block: Block, generator: DartGenerator) {
} while (block.getInput('IF' + n));
if (block.getInput('ELSE') || generator.STATEMENT_SUFFIX) {
branchCode = generator.statementToCode(block, 'ELSE');
branchCode = block.getInput('ELSE')
? generator.statementToCode(block, 'ELSE')
: '';
if (generator.STATEMENT_SUFFIX) {
branchCode =
generator.prefixLines(

View File

@@ -44,7 +44,9 @@ export function controls_if(block: Block, generator: JavascriptGenerator) {
} while (block.getInput('IF' + n));
if (block.getInput('ELSE') || generator.STATEMENT_SUFFIX) {
let branchCode = generator.statementToCode(block, 'ELSE');
let branchCode = block.getInput('ELSE')
? generator.statementToCode(block, 'ELSE')
: '';
if (generator.STATEMENT_SUFFIX) {
branchCode =
generator.prefixLines(

View File

@@ -39,7 +39,9 @@ export function controls_if(block: Block, generator: LuaGenerator): string {
} while (block.getInput('IF' + n));
if (block.getInput('ELSE') || generator.STATEMENT_SUFFIX) {
let branchCode = generator.statementToCode(block, 'ELSE');
let branchCode = block.getInput('ELSE')
? generator.statementToCode(block, 'ELSE')
: '';
if (generator.STATEMENT_SUFFIX) {
branchCode =
generator.prefixLines(

View File

@@ -46,7 +46,9 @@ export function controls_if(block: Block, generator: PhpGenerator) {
} while (block.getInput('IF' + n));
if (block.getInput('ELSE') || generator.STATEMENT_SUFFIX) {
branchCode = generator.statementToCode(block, 'ELSE');
branchCode = block.getInput('ELSE')
? generator.statementToCode(block, 'ELSE')
: '';
if (generator.STATEMENT_SUFFIX) {
branchCode =
generator.prefixLines(

View File

@@ -40,7 +40,11 @@ export function controls_if(block: Block, generator: PythonGenerator) {
} while (block.getInput('IF' + n));
if (block.getInput('ELSE') || generator.STATEMENT_SUFFIX) {
branchCode = generator.statementToCode(block, 'ELSE') || generator.PASS;
if (block.getInput('ELSE')) {
branchCode = generator.statementToCode(block, 'ELSE') || generator.PASS;
} else {
branchCode = generator.PASS;
}
if (generator.STATEMENT_SUFFIX) {
branchCode =
generator.prefixLines(

1260
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -115,7 +115,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsdoc": "^50.5.0",
"eslint-plugin-prettier": "^5.2.1",
"glob": "^10.3.4",
"glob": "^11.0.1",
"globals": "^15.12.0",
"google-closure-compiler": "^20240317.0.0",
"gulp": "^5.0.0",

View File

@@ -38,7 +38,7 @@ function start() {
* If some tests are failing, load test suites individually to continue
* debugging.
*/
function loadSelected() {
async function loadSelected() {
var output = document.getElementById('importExport');
output.style.background = 'gray';
@@ -53,9 +53,12 @@ function loadSelected() {
if (boxList[i].checked) {
var testUrl = boxList[i].value;
if (testUrl) {
var xmlText = fetchFile(testUrl);
var xmlText = await fetchFile(testUrl);
if (xmlText !== null) {
fromXml(testUrl, xmlText, /* opt_append */ true);
// Clean up the workspace to normalize the position of blocks and
// thus the order of functions in the generated code.
Blockly.getMainWorkspace().cleanUp();
}
}
}
@@ -67,23 +70,24 @@ function loadSelected() {
/**
* Ask the user for a file name, then load that file's contents.
*/
function loadOther() {
async function loadOther() {
var url = window.prompt('Enter URL of test file.');
if (!url) {
return;
}
var xmlText = fetchFile(url);
var xmlText = await fetchFile(url);
if (xmlText !== null) {
fromXml(url, xmlText);
}
}
function fetchFile(xmlUrl) {
async function fetchFile(xmlUrl) {
try {
var xmlHttp = new XMLHttpRequest();
xmlHttp.open('GET', xmlUrl, false);
xmlHttp.setRequestHeader('Content-Type', 'text/xml');
xmlHttp.send('');
const response = await fetch(xmlUrl);
if (!response.ok) {
throw new Error(`Got a 404 when loading ${xmlUrl}`);
}
return response.text();
} catch (e) {
// Attempt to diagnose the problem.
var msg = 'Error: Unable to load XML data.\n';
@@ -95,7 +99,6 @@ function fetchFile(xmlUrl) {
alert(msg + '\n' + e);
return null;
}
return xmlHttp.responseText;
}
/**
@@ -188,7 +191,7 @@ function toDart() {
function changeIndex() {
var oneBasedIndex = document.getElementById('indexing').checked;
demoWorkspace.options.oneBasedIndex = oneBasedIndex;
demoWorkspace.getToolbox().flyout_.workspace_.options.oneBasedIndex = oneBasedIndex;
demoWorkspace.getToolbox().getFlyout().getWorkspace().options.oneBasedIndex = oneBasedIndex;
}
</script>

View File

@@ -78,7 +78,7 @@ async function runGeneratorsInBrowser(outputDir) {
await browser.execute(function() {
checkAll();
loadSelected();
return loadSelected();
});
await runLangGeneratorInBrowser(browser, prefix + '.js',

View File

@@ -181,16 +181,58 @@ suite('Lists', function () {
* Test cases for serialization tests.
* @type {Array<SerializationTestCase>}
*/
const testCases = makeTestCasesForBlockNotNeedingExtraState_(
const testCases = [
{
'type': 'lists_split',
'id': '1',
'fields': {
'MODE': 'SPLIT',
title: 'JSON for splitting',
json: {
type: 'lists_split',
id: '1',
extraState: {mode: 'SPLIT'},
fields: {MODE: 'SPLIT'},
inputs: {
DELIM: {
shadow: {
type: 'text',
id: '2',
fields: {
TEXT: ',',
},
},
},
},
},
assertBlockStructure: (block) => {
assert.equal(block.type, 'lists_split');
assert.deepEqual(block.outputConnection.getCheck(), ['Array']);
assert.isTrue(block.getField('MODE').getValue() === 'SPLIT');
},
},
'<mutation mode="SPLIT"></mutation>',
);
{
title: 'JSON for joining',
json: {
type: 'lists_split',
id: '1',
extraState: {mode: 'JOIN'},
fields: {MODE: 'JOIN'},
inputs: {
DELIM: {
shadow: {
type: 'text',
id: '2',
fields: {
TEXT: ',',
},
},
},
},
},
assertBlockStructure: (block) => {
assert.equal(block.type, 'lists_split');
assert.deepEqual(block.outputConnection.getCheck(), ['String']);
assert.isTrue(block.getField('MODE').getValue() === 'JOIN');
},
},
];
runSerializationTestSuite(testCases);
});
});

View File

@@ -61,6 +61,30 @@ suite('Clipboard', function () {
);
});
test('copied from a mutator pastes them into the mutator', async function () {
const block = Blockly.serialization.blocks.append(
{
'type': 'controls_if',
'id': 'blockId',
'extraState': {
'elseIfCount': 1,
},
},
this.workspace,
);
const mutatorIcon = block.getIcon(Blockly.icons.IconType.MUTATOR);
await mutatorIcon.setBubbleVisible(true);
const mutatorWorkspace = mutatorIcon.getWorkspace();
const elseIf = mutatorWorkspace.getBlocksByType('controls_if_elseif')[0];
assert.notEqual(elseIf, undefined);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
const data = elseIf.toCopyData();
Blockly.clipboard.paste(data, mutatorWorkspace);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 3);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
});
suite('pasted blocks are placed in unambiguous locations', function () {
test('pasted blocks are bumped to not overlap', function () {
const block = Blockly.serialization.blocks.append(

View File

@@ -503,8 +503,9 @@ suite('Connection checker', function () {
</xml>`),
this.workspace,
);
[this.blockA, this.blockB, this.blockC] =
this.workspace.getAllBlocks(true);
this.blockA = this.workspace.getBlockById('A');
this.blockB = this.workspace.getBlockById('B');
this.blockC = this.workspace.getBlockById('C');
this.checker = this.workspace.connectionChecker;
});

View File

@@ -408,6 +408,53 @@ suite('WorkspaceSvg', function () {
});
suite('cleanUp', function () {
assert.blockIsAtOrigin = function (actual, message) {
assert.blockHasPosition(actual, 0, 0, message || 'block is at origin');
};
assert.blockHasPositionX = function (actual, expectedX, message) {
const position = actual.getRelativeToSurfaceXY();
message = message || 'block has x value of ' + expectedX;
assert.equal(position.x, expectedX, message);
};
assert.blockHasPositionY = function (actual, expectedY, message) {
const position = actual.getRelativeToSurfaceXY();
message = message || 'block has y value of ' + expectedY;
assert.equal(position.y, expectedY, message);
};
assert.blockHasPosition = function (actual, expectedX, expectedY, message) {
assert.blockHasPositionX(actual, expectedX, message);
assert.blockHasPositionY(actual, expectedY, message);
};
assert.blockIsAtNotOrigin = function (actual, message) {
const position = actual.getRelativeToSurfaceXY();
message = message || 'block is not at origin';
assert.isTrue(position.x != 0 || position.y != 0, message);
};
assert.blocksDoNotIntersect = function (a, b, message) {
const rectA = a.getBoundingRectangle();
const rectB = b.getBoundingRectangle();
assert.isFalse(rectA.intersects(rectB), message || "a,b don't intersect");
};
assert.blockIsAbove = function (a, b, message) {
// Block a is above b iff a's bottom extreme is < b's top extreme.
const rectA = a.getBoundingRectangle();
const rectB = b.getBoundingRectangle();
assert.isBelow(rectA.bottom, rectB.top, message || 'a is above b');
};
assert.blockIsBelow = function (a, b, message) {
// Block a is below b iff a's top extreme is > b's bottom extreme.
const rectA = a.getBoundingRectangle();
const rectB = b.getBoundingRectangle();
assert.isAbove(rectA.top, rectB.bottom, message || 'a is below b');
};
test('empty workspace does not change', function () {
this.workspace.cleanUp();
@@ -429,13 +476,8 @@ suite('WorkspaceSvg', function () {
this.workspace.cleanUp();
const blocks = this.workspace.getTopBlocks(true);
const origin = new Blockly.utils.Coordinate(0, 0);
assert.equal(blocks.length, 1, 'workspace has one top-level block');
assert.deepEqual(
blocks[0].getRelativeToSurfaceXY(),
origin,
'block is at origin',
);
assert.blockIsAtOrigin(blocks[0]);
});
test('single block at (10, 15) is moved to (0, 0)', function () {
@@ -453,14 +495,9 @@ suite('WorkspaceSvg', function () {
const topBlocks = this.workspace.getTopBlocks(true);
const allBlocks = this.workspace.getAllBlocks(false);
const origin = new Blockly.utils.Coordinate(0, 0);
assert.equal(topBlocks.length, 1, 'workspace has one top-level block');
assert.equal(allBlocks.length, 1, 'workspace has one block overall');
assert.deepEqual(
topBlocks[0].getRelativeToSurfaceXY(),
origin,
'block is at origin',
);
assert.blockIsAtOrigin(topBlocks[0]);
});
test('single block at (10, 15) with child is moved as unit to (0, 0)', function () {
@@ -487,19 +524,10 @@ suite('WorkspaceSvg', function () {
const topBlocks = this.workspace.getTopBlocks(true);
const allBlocks = this.workspace.getAllBlocks(false);
const origin = new Blockly.utils.Coordinate(0, 0);
assert.equal(topBlocks.length, 1, 'workspace has one top-level block');
assert.equal(allBlocks.length, 2, 'workspace has two blocks overall');
assert.deepEqual(
topBlocks[0].getRelativeToSurfaceXY(),
origin,
'block is at origin',
);
assert.notDeepEqual(
allBlocks[1].getRelativeToSurfaceXY(),
origin,
'child is not at origin',
);
assert.blockIsAtOrigin(topBlocks[0]); // Parent block.
assert.blockIsAtNotOrigin(allBlocks[1]); // Child block.
});
// TODO(#8676): Reenable once test passes reliably.
@@ -524,19 +552,9 @@ suite('WorkspaceSvg', function () {
const topBlocks = this.workspace.getTopBlocks(true);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const origin = new Blockly.utils.Coordinate(0, 0);
const belowBlock2 = new Blockly.utils.Coordinate(0, 50);
assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks');
assert.deepEqual(
block2.getRelativeToSurfaceXY(),
origin,
'block2 is at origin',
);
assert.deepEqual(
block1.getRelativeToSurfaceXY(),
belowBlock2,
'block1 is below block2',
);
assert.blockIsAtOrigin(block2);
assert.blockIsBelow(block1, block2);
});
// TODO(#8676): Reenable once test passes reliably.
@@ -564,19 +582,9 @@ suite('WorkspaceSvg', function () {
const topBlocks = this.workspace.getTopBlocks(true);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const origin = new Blockly.utils.Coordinate(0, 0);
const belowBlock1 = new Blockly.utils.Coordinate(0, 50);
assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks');
assert.deepEqual(
block1.getRelativeToSurfaceXY(),
origin,
'block1 is at origin',
);
assert.deepEqual(
block2.getRelativeToSurfaceXY(),
belowBlock1,
'block2 is below block1',
);
assert.blockIsAtOrigin(block1);
assert.blockIsBelow(block2, block1);
});
test('two overlapping blocks with snapping are moved to grid-aligned positions', function () {
@@ -605,19 +613,9 @@ suite('WorkspaceSvg', function () {
const topBlocks = this.workspace.getTopBlocks(true);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const snappedOffOrigin = new Blockly.utils.Coordinate(10, 10);
const belowBlock1 = new Blockly.utils.Coordinate(10, 70);
assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks');
assert.deepEqual(
block1.getRelativeToSurfaceXY(),
snappedOffOrigin,
'block1 is near origin',
);
assert.deepEqual(
block2.getRelativeToSurfaceXY(),
belowBlock1,
'block2 is below block1',
);
assert.blockHasPosition(block1, 10, 10, 'block1 is at snapped origin');
assert.blockIsBelow(block2, block1);
});
// TODO(#8676): Reenable once test passes reliably.
@@ -653,36 +651,28 @@ suite('WorkspaceSvg', function () {
const allBlocks = this.workspace.getAllBlocks(false);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const origin = new Blockly.utils.Coordinate(0, 0);
const belowBlock1 = new Blockly.utils.Coordinate(0, 50);
const block1Pos = block1.getRelativeToSurfaceXY();
const block2Pos = block2.getRelativeToSurfaceXY();
const block1ChildPos = block1.getChildren()[0].getRelativeToSurfaceXY();
const block2ChildPos = block2.getChildren()[0].getRelativeToSurfaceXY();
const block1Child = block1.getChildren()[0];
const block2Child = block2.getChildren()[0];
// Note that the x position tests below are verifying that each block's
// child isn't exactly aligned with it (however, they does overlap since
// the child block has an input connection with its parent).
assert.equal(topBlocks.length, 2, 'workspace has two top-level block2');
assert.equal(allBlocks.length, 4, 'workspace has four blocks overall');
assert.deepEqual(block1Pos, origin, 'block1 is at origin');
assert.deepEqual(block2Pos, belowBlock1, 'block2 is below block1');
assert.blockIsAtOrigin(block1);
assert.blockIsBelow(block2, block1);
assert.isAbove(
block1ChildPos.x,
block1Pos.x,
"block1's child is right of it",
);
assert.isBelow(
block1ChildPos.y,
block2Pos.y,
"block1's child is above block 2",
block1.getChildren()[0].getRelativeToSurfaceXY().x,
block1.getRelativeToSurfaceXY().x,
"block1's child is right of its start",
);
assert.blockIsAbove(block1Child, block2);
assert.isAbove(
block2ChildPos.x,
block2Pos.x,
"block2's child is right of it",
);
assert.isAbove(
block2ChildPos.y,
block1Pos.y,
"block2's child is below block 1",
block2.getChildren()[0].getRelativeToSurfaceXY().x,
block2.getRelativeToSurfaceXY().x,
"block2's child is right of its start",
);
assert.blockIsBelow(block2Child, block1);
});
// TODO(#8676): Reenable once test passes reliably.
@@ -742,19 +732,9 @@ suite('WorkspaceSvg', function () {
const topBlocks = this.workspace.getTopBlocks(true);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const origin = new Blockly.utils.Coordinate(0, 0);
const belowBlock1 = new Blockly.utils.Coordinate(0, 144);
assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks');
assert.deepEqual(
block1.getRelativeToSurfaceXY(),
origin,
'block1 is at origin',
);
assert.deepEqual(
block2.getRelativeToSurfaceXY(),
belowBlock1,
'block2 is below block1',
);
assert.blockIsAtOrigin(block1);
assert.blockIsBelow(block2, block1);
});
test('five overlapping blocks are moved in-order as one column', function () {
@@ -780,32 +760,21 @@ suite('WorkspaceSvg', function () {
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const block1Pos = this.workspace
.getBlockById('block1')
.getRelativeToSurfaceXY();
const block2Pos = this.workspace
.getBlockById('block2')
.getRelativeToSurfaceXY();
const block3Pos = this.workspace
.getBlockById('block3')
.getRelativeToSurfaceXY();
const block4Pos = this.workspace
.getBlockById('block4')
.getRelativeToSurfaceXY();
const block5Pos = this.workspace
.getBlockById('block5')
.getRelativeToSurfaceXY();
const origin = new Blockly.utils.Coordinate(0, 0);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const block3 = this.workspace.getBlockById('block3');
const block4 = this.workspace.getBlockById('block4');
const block5 = this.workspace.getBlockById('block5');
assert.equal(topBlocks.length, 5, 'workspace has five top-level blocks');
assert.deepEqual(block1Pos, origin, 'block1 is at origin');
assert.equal(block2Pos.x, 0, 'block2.x is at 0');
assert.equal(block3Pos.x, 0, 'block3.x is at 0');
assert.equal(block4Pos.x, 0, 'block4.x is at 0');
assert.equal(block5Pos.x, 0, 'block5.x is at 0');
assert.isAbove(block2Pos.y, block1Pos.y, 'block2 is below block1');
assert.isAbove(block3Pos.y, block2Pos.y, 'block3 is below block2');
assert.isAbove(block4Pos.y, block3Pos.y, 'block4 is below block3');
assert.isAbove(block5Pos.y, block4Pos.y, 'block5 is below block4');
assert.blockIsAtOrigin(block1);
assert.blockHasPositionX(block2, 0);
assert.blockHasPositionX(block3, 0);
assert.blockHasPositionX(block4, 0);
assert.blockHasPositionX(block5, 0);
assert.blockIsBelow(block2, block1);
assert.blockIsBelow(block3, block2);
assert.blockIsBelow(block4, block3);
assert.blockIsBelow(block5, block4);
});
test('single immovable block at (10, 15) is not moved', function () {
@@ -824,14 +793,9 @@ suite('WorkspaceSvg', function () {
const topBlocks = this.workspace.getTopBlocks(true);
const allBlocks = this.workspace.getAllBlocks(false);
const origPos = new Blockly.utils.Coordinate(10, 15);
assert.equal(topBlocks.length, 1, 'workspace has one top-level block');
assert.equal(allBlocks.length, 1, 'workspace has one block overall');
assert.deepEqual(
topBlocks[0].getRelativeToSurfaceXY(),
origPos,
'block is at (10, 15)',
);
assert.blockHasPosition(topBlocks[0], 10, 15);
});
test('multiple block types immovable blocks are not moved', function () {
@@ -914,53 +878,29 @@ suite('WorkspaceSvg', function () {
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const block1Rect = this.workspace
.getBlockById('block1')
.getBoundingRectangle();
const block2Rect = this.workspace
.getBlockById('block2')
.getBoundingRectangle();
const block3Rect = this.workspace
.getBlockById('block3')
.getBoundingRectangle();
const block4Rect = this.workspace
.getBlockById('block4')
.getBoundingRectangle();
const block5Rect = this.workspace
.getBlockById('block5')
.getBoundingRectangle();
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const block3 = this.workspace.getBlockById('block3');
const block4 = this.workspace.getBlockById('block4');
const block5 = this.workspace.getBlockById('block5');
assert.equal(topBlocks.length, 5, 'workspace has five top-level blocks');
// Check that immovable blocks haven't moved.
assert.equal(block2Rect.left, 10, 'block2.x is at 10');
assert.equal(block2Rect.top, 20, 'block2.y is at 20');
assert.equal(block5Rect.left, 20, 'block5.x is at 20');
assert.equal(block5Rect.top, 200, 'block5.y is at 200');
assert.blockHasPosition(block2, 10, 20);
assert.blockHasPosition(block5, 20, 200);
// Check that movable positions have correctly been left-aligned.
assert.equal(block1Rect.left, 0, 'block1.x is at 0');
assert.equal(block3Rect.left, 0, 'block3.x is at 0');
assert.equal(block4Rect.left, 0, 'block4.x is at 0');
assert.blockHasPositionX(block1, 0);
assert.blockHasPositionX(block3, 0);
assert.blockHasPositionX(block4, 0);
// Block order should be: 2, 1, 3, 5, 4 since 2 and 5 are immovable.
assert.isAbove(block1Rect.top, block2Rect.top, 'block1 is below block2');
assert.isAbove(block3Rect.top, block1Rect.top, 'block3 is below block1');
assert.isAbove(block5Rect.top, block3Rect.top, 'block5 is below block3');
assert.isAbove(block4Rect.top, block5Rect.top, 'block4 is below block5');
assert.blockIsBelow(block1, block2);
assert.blockIsBelow(block3, block1);
assert.blockIsBelow(block5, block3);
assert.blockIsBelow(block4, block5);
// Ensure no blocks intersect (can check in order due to the position verification above).
assert.isFalse(
block2Rect.intersects(block1Rect),
'block2/block1 do not intersect',
);
assert.isFalse(
block1Rect.intersects(block3Rect),
'block1/block3 do not intersect',
);
assert.isFalse(
block3Rect.intersects(block5Rect),
'block3/block5 do not intersect',
);
assert.isFalse(
block5Rect.intersects(block4Rect),
'block5/block4 do not intersect',
);
assert.blocksDoNotIntersect(block2, block1);
assert.blocksDoNotIntersect(block1, block3);
assert.blocksDoNotIntersect(block3, block5);
assert.blocksDoNotIntersect(block5, block4);
});
});