Merge pull request #9592 from RaspberryPiFoundation/rollup

chore: Merge `main` into `v13`
This commit is contained in:
Aaron Dodson
2026-02-26 13:25:27 -08:00
committed by GitHub
17 changed files with 1179 additions and 12105 deletions
+1011 -1038
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -19,6 +19,10 @@
"workspaces": [
"packages/*"
],
"devDependencies": {
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0"
},
"scripts": {
"test": "npm run test --ws --if-present",
"lint": "npm run lint --ws --if-present",
+5 -3
View File
@@ -354,8 +354,8 @@ export class BlockSvg
* @returns Object with .x and .y properties in workspace coordinates.
*/
override getRelativeToSurfaceXY(): Coordinate {
const layerManger = this.workspace.getLayerManager();
if (!layerManger) {
const layerManager = this.workspace.getLayerManager();
if (!layerManager) {
throw new Error(
'Cannot calculate position because the workspace has not been appended',
);
@@ -371,7 +371,7 @@ export class BlockSvg
x += xy.x;
y += xy.y;
element = element.parentNode as SVGElement;
} while (element && !layerManger.hasLayer(element));
} while (element && !layerManager.hasLayer(element));
}
return new Coordinate(x, y);
}
@@ -1133,7 +1133,9 @@ export class BlockSvg
if (this.isDeadOrDying()) return;
const gesture = this.workspace.getGesture(e);
if (gesture) {
this.bringToFront();
gesture.setStartIcon(icon);
getFocusManager().focusNode(icon);
}
};
}
+8 -1
View File
@@ -93,7 +93,14 @@ let content = `
.blocklyBlockCanvas.blocklyCanvasTransitioning,
.blocklyBubbleCanvas.blocklyCanvasTransitioning {
transition: transform .5s;
transition: transform .15s;
}
@media (prefers-reduced-motion) {
.blocklyBlockCanvas.blocklyCanvasTransitioning,
.blocklyBubbleCanvas.blocklyCanvasTransitioning {
transition: none;
}
}
.blocklyEmboss {
+14 -60
View File
@@ -38,7 +38,6 @@ import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js';
import {Svg} from './utils/svg.js';
import * as toolbox from './utils/toolbox.js';
import * as Variables from './variables.js';
import {WorkspaceSvg} from './workspace_svg.js';
/**
@@ -813,44 +812,23 @@ export abstract class Flyout
* @internal
*/
createBlock(originalBlock: BlockSvg): BlockSvg {
let newBlock = null;
eventUtils.disable();
const variablesBeforeCreation = this.targetWorkspace
.getVariableMap()
.getAllVariables();
this.targetWorkspace.setResizesEnabled(false);
try {
newBlock = this.placeNewBlock(originalBlock);
} finally {
eventUtils.enable();
const targetWorkspace = this.targetWorkspace;
const svgRootOld = originalBlock.getSvgRoot();
if (!svgRootOld) {
throw Error('oldBlock is not rendered');
}
// Close the flyout.
this.targetWorkspace.hideChaff();
// Clone the block.
const json = this.serializeBlock(originalBlock);
// Normally this resizes leading to weird jumps. Save it for terminateDrag.
targetWorkspace.setResizesEnabled(false);
const block = blocks.appendInternal(json, targetWorkspace, {
recordUndo: true,
}) as BlockSvg;
const newVariables = Variables.getAddedVariables(
this.targetWorkspace,
variablesBeforeCreation,
);
if (eventUtils.isEnabled()) {
eventUtils.setGroup(true);
// Fire a VarCreate event for each (if any) new variable created.
for (let i = 0; i < newVariables.length; i++) {
const thisVariable = newVariables[i];
eventUtils.fire(
new (eventUtils.get(EventType.VAR_CREATE))(thisVariable),
);
}
// Block events come after var events, in case they refer to newly created
// variables.
eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(newBlock));
}
if (this.autoClose) {
this.hide();
}
return newBlock;
this.positionNewBlock(originalBlock, block);
targetWorkspace.hideChaff();
return block;
}
/**
@@ -873,30 +851,6 @@ export abstract class Flyout
: false;
}
/**
* Copy a block from the flyout to the workspace and position it correctly.
*
* @param oldBlock The flyout block to copy.
* @returns The new block in the main workspace.
*/
private placeNewBlock(oldBlock: BlockSvg): BlockSvg {
const targetWorkspace = this.targetWorkspace;
const svgRootOld = oldBlock.getSvgRoot();
if (!svgRootOld) {
throw Error('oldBlock is not rendered');
}
// Clone the block.
const json = this.serializeBlock(oldBlock);
// Normally this resizes leading to weird jumps. Save it for terminateDrag.
targetWorkspace.setResizesEnabled(false);
const block = blocks.append(json, targetWorkspace) as BlockSvg;
this.positionNewBlock(oldBlock, block);
return block;
}
/**
* Serialize a block to JSON.
*
+7 -1
View File
@@ -427,7 +427,13 @@ Css.register(`
fill: #666;
}
.blocklyFlyoutButton:hover {
@media (hover: hover) {
.blocklyFlyoutButton:hover {
fill: #aaa;
}
}
.blocklyFlyoutButton:active {
fill: #aaa;
}
+28 -56
View File
@@ -27,6 +27,7 @@ import * as eventUtils from './events/utils.js';
import type {Field} from './field.js';
import {getFocusManager} from './focus_manager.js';
import type {IBubble} from './interfaces/i_bubble.js';
import {hasContextMenu} from './interfaces/i_contextmenu.js';
import {IDraggable, isDraggable} from './interfaces/i_draggable.js';
import {IDragger} from './interfaces/i_dragger.js';
import type {IFlyout} from './interfaces/i_flyout.js';
@@ -273,24 +274,19 @@ export class Gesture {
throw new Error(`Cannot update dragging from the flyout because the ' +
'flyout's target workspace is undefined`);
}
if (
!this.flyout.isScrollable() ||
this.flyout.isDragTowardWorkspace(this.currentDragDeltaXY)
) {
this.startWorkspace_ = this.flyout.targetWorkspace;
this.startWorkspace_.updateScreenCalculationsIfScrolled();
// Start the event group now, so that the same event group is used for
// block creation and block dragging.
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
// The start block is no longer relevant, because this is a drag.
this.startBlock = null;
this.targetBlock = this.flyout.createBlock(this.targetBlock);
getFocusManager().focusNode(this.targetBlock);
return true;
this.startWorkspace_ = this.flyout.targetWorkspace;
this.startWorkspace_.updateScreenCalculationsIfScrolled();
// Start the event group now, so that the same event group is used for
// block creation and block dragging.
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
return false;
// The start block is no longer relevant, because this is a drag.
this.startBlock = null;
this.targetBlock = this.flyout.createBlock(this.targetBlock);
getFocusManager().focusNode(this.targetBlock);
return true;
}
/**
@@ -732,22 +728,12 @@ export class Gesture {
* @internal
*/
handleRightClick(e: PointerEvent) {
if (this.targetBlock) {
this.bringBlockToFront();
this.targetBlock.workspace.hideChaff(!!this.flyout);
this.targetBlock.showContextMenu(e);
} else if (this.startBubble) {
this.startBubble.showContextMenu(e);
} else if (this.startComment) {
this.startComment.workspace.hideChaff();
this.startComment.showContextMenu(e);
} else if (this.startWorkspace_ && !this.flyout) {
this.startWorkspace_.hideChaff();
getFocusManager().focusNode(this.startWorkspace_);
this.startWorkspace_.showContextMenu(e);
const selection = getFocusManager().getFocusedNode();
if (hasContextMenu(selection)) {
this.startWorkspace_?.hideChaff(!!this.flyout);
selection.showContextMenu(e);
}
// TODO: Handle right-click on a bubble.
e.preventDefault();
e.stopPropagation();
@@ -773,7 +759,12 @@ export class Gesture {
this.setStartWorkspace(ws);
this.mostRecentEvent = e;
if (!this.targetBlock && !this.startBubble && !this.startComment) {
if (
!this.targetBlock &&
!this.startBubble &&
!this.startComment &&
!this.startIcon
) {
// Ensure the workspace is selected if nothing else should be. Note that
// this is focusNode() instead of focusTree() because if any active node
// is focused in the workspace it should be defocused.
@@ -878,12 +869,6 @@ export class Gesture {
);
}
// Note that the order is important here: bringing a block to the front will
// cause it to become focused and showing the field editor will capture
// focus ephemerally. It's important to ensure that focus is properly
// restored back to the block after field editing has completed.
this.bringBlockToFront();
// Only show the editor if the field's editor wasn't already open
// right before this gesture started.
const dropdownAlreadyOpen = this.currentDropdownOwner === this.startField;
@@ -899,7 +884,6 @@ export class Gesture {
'Cannot do an icon click because the start icon is undefined',
);
}
this.bringBlockToFront();
this.startIcon.onClick();
}
@@ -938,7 +922,6 @@ export class Gesture {
);
eventUtils.fire(event);
}
this.bringBlockToFront();
eventUtils.setGroup(false);
}
@@ -957,19 +940,6 @@ export class Gesture {
// TODO (fenichel): Move bubbles to the front.
/**
* Move the dragged/clicked block to the front of the workspace so that it is
* not occluded by other blocks.
*/
private bringBlockToFront() {
// Blocks in the flyout don't overlap, so skip the work.
if (this.targetBlock && !this.flyout) {
// Always ensure the block being dragged/clicked has focus.
getFocusManager().focusNode(this.targetBlock);
this.targetBlock.bringToFront();
}
}
/* Begin functions for populating a gesture at pointerdown. */
/**
@@ -1039,8 +1009,9 @@ export class Gesture {
* @internal
*/
setStartBlock(block: BlockSvg) {
// If the gesture already went through a bubble, don't set the start block.
if (!this.startBlock && !this.startBubble) {
// If the gesture already went through a block child, don't set the start
// block.
if (!this.startBlock && !this.startBubble && !this.startIcon) {
this.startBlock = block;
if (block.isInFlyout && block !== block.getRootBlock()) {
this.setTargetBlock(block.getRootBlock());
@@ -1064,7 +1035,8 @@ export class Gesture {
this.setTargetBlock(block.getParent()!);
} else {
this.targetBlock = block;
getFocusManager().focusNode(block);
getFocusManager().focusNode(this.targetBlock);
this.targetBlock.bringToFront();
}
}
+6 -1
View File
@@ -7,6 +7,7 @@
import type {Block} from '../block.js';
import type {BlockSvg} from '../block_svg.js';
import * as browserEvents from '../browser_events.js';
import type {IContextMenu} from '../interfaces/i_contextmenu.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import {hasBubble} from '../interfaces/i_has_bubble.js';
import type {IIcon} from '../interfaces/i_icon.js';
@@ -26,7 +27,7 @@ import type {IconType} from './icon_types.js';
* block (such as warnings or comments) as opposed to fields, which provide
* "actual" information, related to how a block functions.
*/
export abstract class Icon implements IIcon {
export abstract class Icon implements IIcon, IContextMenu {
/**
* The position of this icon relative to its blocks top-start,
* in workspace units.
@@ -196,4 +197,8 @@ export abstract class Icon implements IIcon {
getSourceBlock(): Block {
return this.sourceBlock;
}
showContextMenu(e: PointerEvent) {
(this.getSourceBlock() as BlockSvg).showContextMenu(e);
}
}
@@ -14,3 +14,8 @@ export interface IContextMenu {
*/
showContextMenu(e: Event): void;
}
/** @returns true if the given object implements IContextMenu. */
export function hasContextMenu(obj: any): obj is IContextMenu {
return obj && typeof obj.showContextMenu === 'function';
}
+8 -6
View File
@@ -73,11 +73,11 @@ export class LayerManager {
* @internal
*/
appendToAnimationLayer(elem: IRenderedElement) {
const currentTransform = this.dragLayer?.getAttribute('transform');
const currentTransform = this.dragLayer?.style.transform;
// Only update the current transform when appending, so animations don't
// move if the workspace moves.
if (currentTransform) {
this.animationLayer?.setAttribute('transform', currentTransform);
if (currentTransform && this.animationLayer) {
this.animationLayer.style.transform = currentTransform;
}
this.animationLayer?.appendChild(elem.getSvgRoot());
}
@@ -88,10 +88,12 @@ export class LayerManager {
* @internal
*/
translateLayers(newCoord: Coordinate, newScale: number) {
const translation = `translate(${newCoord.x}, ${newCoord.y}) scale(${newScale})`;
this.dragLayer?.setAttribute('transform', translation);
const translation = `translate(${newCoord.x}px, ${newCoord.y}px) scale(${newScale})`;
if (this.dragLayer) {
this.dragLayer.style.transform = translation;
}
for (const [_, layer] of this.layers) {
layer.setAttribute('transform', translation);
layer.style.transform = translation;
}
}
+4 -4
View File
@@ -2467,17 +2467,17 @@ export class WorkspaceSvg
* valid gesture exists.
* @internal
*/
getGesture(e: PointerEvent): Gesture | null {
getGesture(e?: PointerEvent): Gesture | null {
// TODO(#8960): Query Mover.isMoving to see if move is in progress
// rather than relying on .keyboardMoveInProgress status flag.
if (this.keyboardMoveInProgress) {
// Normally these would be called from Gesture.doStart.
e.preventDefault();
e.stopPropagation();
e?.preventDefault();
e?.stopPropagation();
return null;
}
const isStart = e.type === 'pointerdown';
const isStart = e?.type === 'pointerdown';
if (isStart && this.currentGesture_?.hasStarted()) {
console.warn('Tried to start the same gesture twice.');
// That's funny. We must have missed a mouse up.
+3 -1
View File
@@ -373,8 +373,10 @@ export class ZoomControls implements IPositionable {
* @param e A mouse down event.
*/
private zoom(amount: number, e: PointerEvent) {
this.workspace.beginCanvasTransition();
this.workspace.markFocused();
this.workspace.zoomCenter(amount);
setTimeout(this.workspace.endCanvasTransition.bind(this.workspace), 150);
this.fireZoomEvent();
Touch.clearTouchIdentifier(); // Don't block future drags.
e.stopPropagation(); // Don't start a workspace scroll.
@@ -459,7 +461,7 @@ export class ZoomControls implements IPositionable {
this.workspace.zoomCenter(amount);
this.workspace.scrollCenter();
setTimeout(this.workspace.endCanvasTransition.bind(this.workspace), 500);
setTimeout(this.workspace.endCanvasTransition.bind(this.workspace), 150);
this.fireZoomEvent();
Touch.clearTouchIdentifier(); // Don't block future drags.
e.stopPropagation(); // Don't start a workspace scroll.
-10931
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -43,7 +43,7 @@
"start": "npm run build && concurrently -n tsc,server \"tsc --watch --preserveWatchOutput --outDir \"build/src\" --declarationDir \"build/declarations\"\" \"http-server ./ -s -o /tests/playground.html -c-1\"",
"tsc": "gulp tsc",
"test": "gulp test",
"test:browser": "cd tests/browser && npx mocha",
"test:browser": "npx mocha --config tests/browser/.mocharc.js",
"test:generators": "gulp testGenerators",
"test:mocha:interactive": "npm run build && concurrently -n tsc,server \"tsc --watch --preserveWatchOutput --outDir \"build/src\" --declarationDir \"build/declarations\"\" \"gulp interactiveMocha\"",
"test:compile:advanced": "gulp buildAdvancedCompilationTest --debug",
@@ -104,8 +104,6 @@
"@blockly/dev-tools": "^9.0.2",
"@blockly/keyboard-navigation": "^3.0.1",
"@blockly/theme-modern": "^7.0.1",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@hyperjump/browser": "^1.1.4",
"@hyperjump/json-schema": "^1.5.0",
"@microsoft/api-documenter": "7.22.4",
@@ -3,4 +3,5 @@
module.exports = {
ui: 'tdd',
require: __dirname + '/test/hooks.mjs',
spec: 'tests/browser/test/**/*_test.mjs',
};
+62
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as Blockly from '../../build/src/core/blockly.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineEmptyBlock} from './test_helpers/block_definitions.js';
import {MockIcon, MockSerializableIcon} from './test_helpers/icon_mocks.js';
@@ -11,6 +12,26 @@ import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
import {simulateClick} from './test_helpers/user_input.js';
class TestIcon extends Blockly.icons.Icon {
showContextMenu(e) {
const menuItems = [
{text: 'Test icon menu item', enabled: true, callback: () => {}},
];
Blockly.ContextMenu.show(
e,
menuItems,
false,
this.getSourceBlock().workspace,
this.workspaceLocation,
);
}
getType() {
new Blockly.icons.IconType('test');
}
}
suite('Icon', function () {
setup(function () {
@@ -366,4 +387,45 @@ suite('Icon', function () {
);
});
});
suite('Contextual menus', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {});
Blockly.icons.registry.register(
new Blockly.icons.IconType('test'),
TestIcon,
);
this.block = this.workspace.newBlock('empty_block');
this.block.initSvg();
});
test('are shown when icons are right clicked', function () {
const icon = new TestIcon(this.block);
this.block.addIcon(icon);
simulateClick(icon.getFocusableElement(), {button: 2});
const menu = document.querySelector('.blocklyContextMenu');
assert.isNotNull(menu);
assert.isTrue(menu.innerText.includes('Test icon menu item'));
});
test('default to the contextual menu of the parent block', function () {
this.block.setCommentText('hello there');
const icon = this.block.getIcon(Blockly.icons.IconType.COMMENT);
simulateClick(icon.getFocusableElement(), {button: 2});
const expectedItems =
Blockly.ContextMenuRegistry.registry.getContextMenuOptions({
block: this.block,
});
assert.isNotEmpty(expectedItems);
const menu = document.querySelector('.blocklyContextMenu');
for (const item of expectedItems) {
if (!item.text) continue;
assert.isTrue(menu.innerText.includes(item.text));
}
});
});
});
@@ -16,6 +16,7 @@ import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
import {dispatchPointerEvent} from './test_helpers/user_input.js';
import {testAWorkspace} from './test_helpers/workspace.js';
suite('WorkspaceSvg', function () {
@@ -114,6 +115,17 @@ suite('WorkspaceSvg', function () {
assert.equal(true, shadowBlock.isDeadOrDying());
});
test('getGesture returns null when no gesture is in progress', function () {
const gesture = this.workspace.getGesture();
assert.isNull(gesture);
});
test('getGesture returns the current gesture when one is in progress', function () {
dispatchPointerEvent(this.workspace.getSvgGroup(), 'pointerdown');
const gesture = this.workspace.getGesture();
assert.isNotNull(gesture);
});
suite('updateToolbox', function () {
test('Passes in null when toolbox exists', function () {
assert.throws(