Merge pull request #9335 from google/rc/v12.3.0

release: Merge `rc/v12.3.0` into `master`.
This commit is contained in:
Aaron Dodson
2025-08-28 16:29:00 -07:00
committed by GitHub
159 changed files with 2249 additions and 631 deletions

View File

@@ -15,7 +15,7 @@ jobs:
steps:
# Checks-out the repository under $GITHUB_WORKSPACE.
# When running manually this checks out the master branch.
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Prepare demo files
# Install all dependencies, then copy all the files needed for demos.
@@ -36,13 +36,13 @@ jobs:
needs: prepare
steps:
- name: Download prepared files
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: appengine_files
path: _deploy/
- name: Deploy to App Engine
uses: google-github-actions/deploy-appengine@v2.1.5
uses: google-github-actions/deploy-appengine@v2.1.7
# For parameters see:
# https://github.com/google-github-actions/deploy-appengine#inputs
with:

View File

@@ -5,13 +5,15 @@ name: Run browser manually
on:
workflow_dispatch:
schedule:
- cron: '0 6 * * 1' # Runs every Monday at 06:00 UTC
permissions:
contents: read
jobs:
build:
timeout-minutes: 10
timeout-minutes: 120
runs-on: ${{ matrix.os }}
strategy:
@@ -24,7 +26,7 @@ jobs:
# https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false

View File

@@ -18,12 +18,12 @@ jobs:
# TODO (#2114): re-enable osx build.
# os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest]
node-version: [18.x, 20.x, 22.x]
node-version: [18.x, 20.x, 22.x, 24.x]
# See supported Node.js release schedule at
# https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -54,7 +54,7 @@ jobs:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Use Node.js 20.x
uses: actions/setup-node@v4
@@ -71,7 +71,7 @@ jobs:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Use Node.js 20.x
uses: actions/setup-node@v4

View File

@@ -25,12 +25,12 @@ jobs:
steps:
- name: Checkout core Blockly
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
path: core-blockly
- name: Checkout keyboard navigation plugin
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: 'google/blockly-keyboard-experimentation'
ref: 'main'

View File

@@ -9,7 +9,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: actions/first-interaction@v1
- uses: actions/first-interaction@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: >

View File

@@ -501,22 +501,32 @@ export class Block {
// Detach this block from the parent's tree.
this.previousConnection.disconnect();
}
const nextBlock = this.getNextBlock();
if (opt_healStack && nextBlock && !nextBlock.isShadow()) {
// Disconnect the next statement.
const nextTarget = this.nextConnection?.targetConnection ?? null;
nextTarget?.disconnect();
if (
previousTarget &&
this.workspace.connectionChecker.canConnect(
previousTarget,
nextTarget,
false,
)
) {
// Attach the next statement to the previous statement.
previousTarget.connect(nextTarget!);
}
if (!opt_healStack) return;
// Immovable or shadow next blocks need to move along with the block; keep
// going until we encounter a normal block or run off the end of the stack.
let nextBlock = this.getNextBlock();
while (nextBlock && (nextBlock.isShadow() || !nextBlock.isMovable())) {
nextBlock = nextBlock.getNextBlock();
}
if (!nextBlock) return;
// Disconnect the next statement.
const nextTarget =
nextBlock.previousConnection?.targetBlock()?.nextConnection
?.targetConnection ?? null;
nextTarget?.disconnect();
if (
previousTarget &&
this.workspace.connectionChecker.canConnect(
previousTarget,
nextTarget,
false,
)
) {
// Attach the next statement to the previous statement.
previousTarget.connect(nextTarget!);
}
}
@@ -1116,9 +1126,9 @@ export class Block {
/**
* Returns a generator that provides every field on the block.
*
* @yields A generator that can be used to iterate the fields on the block.
* @returns A generator that can be used to iterate the fields on the block.
*/
*getFields(): Generator<Field> {
*getFields(): Generator<Field, undefined, void> {
for (const input of this.inputList) {
for (const field of input.fieldRow) {
yield field;

View File

@@ -951,9 +951,12 @@ export class BlockSvg
/**
* Encode a block for copying.
*
* @param addNextBlocks If true, copy subsequent blocks attached to this one
* as well.
*
* @returns Copy metadata, or null if the block is an insertion marker.
*/
toCopyData(): BlockCopyData | null {
toCopyData(addNextBlocks = false): BlockCopyData | null {
if (this.isInsertionMarker_) {
return null;
}
@@ -961,7 +964,8 @@ export class BlockSvg
paster: BlockPaster.TYPE,
blockState: blocks.save(this, {
addCoordinates: true,
addNextBlocks: false,
addNextBlocks,
saveIds: false,
}) as blocks.State,
typeCounts: common.getBlockTypeCounts(this, true),
};
@@ -1840,6 +1844,9 @@ export class BlockSvg
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.select();
this.workspace.scrollBoundsIntoView(
this.getBoundingRectangleWithoutChildren(),
);
}
/** See IFocusableNode.onNodeBlur. */

View File

@@ -9,7 +9,9 @@ import * as common from '../common.js';
import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js';
import {getFocusManager} from '../focus_manager.js';
import {IBubble} from '../interfaces/i_bubble.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {ISelectable} from '../interfaces/i_selectable.js';
import {ContainerRegion} from '../metrics_manager.js';
import {Scrollbar} from '../scrollbar.js';
@@ -27,7 +29,7 @@ import {WorkspaceSvg} from '../workspace_svg.js';
* bubble, where it has a "tail" that points to the block, and a "head" that
* displays arbitrary svg elements.
*/
export abstract class Bubble implements IBubble, ISelectable {
export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
/** The width of the border around the bubble. */
static readonly BORDER_WIDTH = 6;
@@ -100,12 +102,14 @@ export abstract class Bubble implements IBubble, ISelectable {
* element that's represented by this bubble (as a focusable node). This
* element will have its ID overwritten. If not provided, the focusable
* element of this node will default to the bubble's SVG root.
* @param owner The object responsible for hosting/spawning this bubble.
*/
constructor(
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
overriddenFocusableElement?: SVGElement | HTMLElement,
protected owner?: IHasBubble & IFocusableNode,
) {
this.id = idGenerator.getNextUniqueId();
this.svgRoot = dom.createSvgElement(
@@ -145,6 +149,13 @@ export abstract class Bubble implements IBubble, ISelectable {
this,
this.onMouseDown,
);
browserEvents.conditionalBind(
this.focusableElement,
'keydown',
this,
this.onKeyDown,
);
}
/** Dispose of this bubble. */
@@ -229,6 +240,19 @@ export abstract class Bubble implements IBubble, ISelectable {
getFocusManager().focusNode(this);
}
/**
* Handles key events when this bubble is focused. By default, closes the
* bubble on Escape.
*
* @param e The keyboard event to handle.
*/
protected onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && this.owner) {
this.owner.setBubbleVisible(false);
getFocusManager().focusNode(this.owner);
}
}
/** Positions the bubble relative to its anchor. Does not render its tail. */
protected positionRelativeToAnchor() {
let left = this.anchor.x;
@@ -683,6 +707,10 @@ export abstract class Bubble implements IBubble, ISelectable {
onNodeFocus(): void {
this.select();
this.bringToFront();
const xy = this.getRelativeToSurfaceXY();
const size = this.getSize();
const bounds = new Rect(xy.y, xy.y + size.height, xy.x, xy.x + size.width);
this.workspace.scrollBoundsIntoView(bounds);
}
/** See IFocusableNode.onNodeBlur. */
@@ -694,4 +722,11 @@ export abstract class Bubble implements IBubble, ISelectable {
canBeFocused(): boolean {
return true;
}
/**
* Returns the object that owns/hosts this bubble, if any.
*/
getOwner(): (IHasBubble & IFocusableNode) | undefined {
return this.owner;
}
}

View File

@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentEditor} from '../comments/comment_editor.js';
import * as Css from '../css.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import * as touch from '../touch.js';
import {browserEvents} from '../utils.js';
import {Coordinate} from '../utils/coordinate.js';
@@ -21,12 +25,6 @@ import {Bubble} from './bubble.js';
* Used by the comment icon.
*/
export class TextInputBubble extends Bubble {
/** The root of the elements specific to the text element. */
private inputRoot: SVGForeignObjectElement;
/** The text input area element. */
private textArea: HTMLTextAreaElement;
/** The group containing the lines indicating the bubble is resizable. */
private resizeGroup: SVGGElement;
@@ -42,18 +40,12 @@ export class TextInputBubble extends Bubble {
*/
private resizePointerMoveListener: browserEvents.Data | null = null;
/** Functions listening for changes to the text of this bubble. */
private textChangeListeners: (() => void)[] = [];
/** Functions listening for changes to the size of this bubble. */
private sizeChangeListeners: (() => void)[] = [];
/** Functions listening for changes to the location of this bubble. */
private locationChangeListeners: (() => void)[] = [];
/** The text of this bubble. */
private text = '';
/** The default size of this bubble, including borders. */
private readonly DEFAULT_SIZE = new Size(
160 + Bubble.DOUBLE_BORDER,
@@ -68,46 +60,47 @@ export class TextInputBubble extends Bubble {
private editable = true;
/** View responsible for supporting text editing. */
private editor: CommentEditor;
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
* The tail of the bubble will point to this location.
* @param ownerRect An optional rect we don't want the bubble to overlap with
* when automatically positioning.
* @param owner The object that owns/hosts this bubble.
*/
constructor(
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
protected owner?: IHasBubble & IFocusableNode,
) {
super(workspace, anchor, ownerRect, TextInputBubble.createTextArea());
super(workspace, anchor, ownerRect, undefined, owner);
dom.addClass(this.svgRoot, 'blocklyTextInputBubble');
this.textArea = this.getFocusableElement() as HTMLTextAreaElement;
this.inputRoot = this.createEditor(this.contentContainer, this.textArea);
this.editor = new CommentEditor(workspace, this.id, () => {
getFocusManager().focusNode(this);
});
this.contentContainer.appendChild(this.editor.getDom());
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
this.setSize(this.DEFAULT_SIZE, true);
}
/** @returns the text of this bubble. */
getText(): string {
return this.text;
return this.editor.getText();
}
/** Sets the text of this bubble. Calls change listeners. */
setText(text: string) {
this.text = text;
this.textArea.value = text;
this.onTextChange();
this.editor.setText(text);
}
/** Sets whether or not the text in the bubble is editable. */
setEditable(editable: boolean) {
this.editable = editable;
if (this.editable) {
this.textArea.removeAttribute('readonly');
} else {
this.textArea.setAttribute('readonly', '');
}
this.editor.setEditable(editable);
}
/** Returns whether or not the text in the bubble is editable. */
@@ -117,7 +110,7 @@ export class TextInputBubble extends Bubble {
/** Adds a change listener to be notified when this bubble's text changes. */
addTextChangeListener(listener: () => void) {
this.textChangeListeners.push(listener);
this.editor.addTextChangeListener(listener);
}
/** Adds a change listener to be notified when this bubble's size changes. */
@@ -130,58 +123,6 @@ export class TextInputBubble extends Bubble {
this.locationChangeListeners.push(listener);
}
/** Creates and returns the editable text area for this bubble's editor. */
private static createTextArea(): HTMLTextAreaElement {
const textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
textArea.className = 'blocklyTextarea blocklyText';
return textArea;
}
/** Creates and returns the UI container element for this bubble's editor. */
private createEditor(
container: SVGGElement,
textArea: HTMLTextAreaElement,
): SVGForeignObjectElement {
const inputRoot = dom.createSvgElement(
Svg.FOREIGNOBJECT,
{
'x': Bubble.BORDER_WIDTH,
'y': Bubble.BORDER_WIDTH,
},
container,
);
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
body.appendChild(textArea);
inputRoot.appendChild(body);
this.bindTextAreaEvents(textArea);
return inputRoot;
}
/** Binds events to the text area element. */
private bindTextAreaEvents(textArea: HTMLTextAreaElement) {
// Don't zoom with mousewheel; let it scroll instead.
browserEvents.conditionalBind(textArea, 'wheel', this, (e: Event) => {
e.stopPropagation();
});
// Don't let the pointerdown event get to the workspace.
browserEvents.conditionalBind(textArea, 'pointerdown', this, (e: Event) => {
e.stopPropagation();
touch.clearTouchIdentifier();
});
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
}
/** Creates the resize handler elements and binds events to them. */
private createResizeHandle(
container: SVGGElement,
@@ -220,8 +161,12 @@ export class TextInputBubble extends Bubble {
const widthMinusBorder = size.width - Bubble.DOUBLE_BORDER;
const heightMinusBorder = size.height - Bubble.DOUBLE_BORDER;
this.inputRoot.setAttribute('width', `${widthMinusBorder}`);
this.inputRoot.setAttribute('height', `${heightMinusBorder}`);
this.editor.updateSize(
new Size(widthMinusBorder, heightMinusBorder),
new Size(0, 0),
);
this.editor.getDom().setAttribute('x', `${Bubble.DOUBLE_BORDER / 2}`);
this.editor.getDom().setAttribute('y', `${Bubble.DOUBLE_BORDER / 2}`);
this.resizeGroup.setAttribute('y', `${heightMinusBorder}`);
if (this.workspace.RTL) {
@@ -312,14 +257,6 @@ export class TextInputBubble extends Bubble {
this.onSizeChange();
}
/** Handles a text change event for the text area. Calls event listeners. */
private onTextChange() {
this.text = this.textArea.value;
for (const listener of this.textChangeListeners) {
listener();
}
}
/** Handles a size change event for the text area. Calls event listeners. */
private onSizeChange() {
for (const listener of this.sizeChangeListeners) {
@@ -333,6 +270,15 @@ export class TextInputBubble extends Bubble {
listener();
}
}
/**
* Returns the text editor component of this bubble.
*
* @internal
*/
getEditor() {
return this.editor;
}
}
Css.register(`

View File

@@ -83,6 +83,9 @@ export function moveBlockToNotConflict(
block: BlockSvg,
originalPosition: Coordinate,
) {
if (block.workspace.RTL) {
originalPosition.x = block.workspace.getWidth() - originalPosition.x;
}
const workspace = block.workspace;
const snapRadius = config.snapRadius;
const bumpOffset = Coordinate.difference(

View File

@@ -10,6 +10,7 @@ import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {CommentBarButton} from './comment_bar_button.js';
import type {CommentView} from './comment_view.js';
/**
* Magic string appended to the comment ID to create a unique ID for this button.
@@ -42,8 +43,9 @@ export class CollapseCommentBarButton extends CommentBarButton {
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
protected readonly commentView: CommentView,
) {
super(id, workspace, container);
super(id, workspace, container, commentView);
this.icon = dom.createSvgElement(
Svg.IMAGE,
@@ -86,14 +88,13 @@ export class CollapseCommentBarButton extends CommentBarButton {
override performAction(e?: Event) {
touch.clearTouchIdentifier();
const comment = this.getParentComment();
comment.view.bringToFront();
this.getCommentView().bringToFront();
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
comment.setCollapsed(!comment.isCollapsed());
this.getCommentView().setCollapsed(!this.getCommentView().isCollapsed());
this.workspace.hideChaff();
e?.stopPropagation();

View File

@@ -7,7 +7,7 @@
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {Rect} from '../utils/rect.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js';
import type {CommentView} from './comment_view.js';
/**
* Button displayed on a comment's top bar.
@@ -29,6 +29,7 @@ export abstract class CommentBarButton implements IFocusableNode {
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
protected readonly commentView: CommentView,
) {}
/**
@@ -39,17 +40,10 @@ export abstract class CommentBarButton implements IFocusableNode {
}
/**
* Returns the parent comment of this comment bar button.
* Returns the parent comment view of this comment bar button.
*/
getParentComment(): RenderedWorkspaceComment {
const comment = this.workspace.getCommentById(this.id);
if (!comment) {
throw new Error(
`Comment bar button ${this.id} has no corresponding comment`,
);
}
return comment;
getCommentView(): CommentView {
return this.commentView;
}
/** Adjusts the position of this button within its parent container. */
@@ -93,7 +87,13 @@ export abstract class CommentBarButton implements IFocusableNode {
}
/** Called when this button's focusable DOM element gains focus. */
onNodeFocus() {}
onNodeFocus() {
const commentView = this.getCommentView();
const xy = commentView.getRelativeToSurfaceXY();
const size = commentView.getSize();
const bounds = new Rect(xy.y, xy.y + size.height, xy.x, xy.x + size.width);
commentView.workspace.scrollBoundsIntoView(bounds);
}
/** Called when this button's focusable DOM element loses focus. */
onNodeBlur() {}

View File

@@ -10,8 +10,10 @@ import {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import * as touch from '../touch.js';
import * as dom from '../utils/dom.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import * as svgMath from '../utils/svg_math.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/**
@@ -53,6 +55,7 @@ export class CommentEditor implements IFocusableNode {
'textarea',
) as HTMLTextAreaElement;
this.textArea.setAttribute('tabindex', '-1');
this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
dom.addClass(this.textArea, 'blocklyCommentText');
dom.addClass(this.textArea, 'blocklyTextarea');
dom.addClass(this.textArea, 'blocklyText');
@@ -86,6 +89,11 @@ export class CommentEditor implements IFocusableNode {
},
);
// Don't zoom with mousewheel; let it scroll instead.
browserEvents.conditionalBind(this.textArea, 'wheel', this, (e: Event) => {
e.stopPropagation();
});
// Register listener for keydown events that would finish editing.
browserEvents.conditionalBind(
this.textArea,
@@ -182,7 +190,16 @@ export class CommentEditor implements IFocusableNode {
getFocusableTree(): IFocusableTree {
return this.workspace;
}
onNodeFocus(): void {}
onNodeFocus(): void {
const bbox = Rect.from(this.foreignObject.getBoundingClientRect());
this.workspace.scrollBoundsIntoView(
Rect.createFromPoint(
svgMath.screenToWsCoordinates(this.workspace, bbox.getOrigin()),
bbox.getWidth(),
bbox.getHeight(),
),
);
}
onNodeBlur(): void {}
canBeFocused(): boolean {
if (this.id) return true;

View File

@@ -102,7 +102,7 @@ export class CommentView implements IRenderedElement {
constructor(
readonly workspace: WorkspaceSvg,
private commentId: string,
readonly commentId: string,
) {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
@@ -176,12 +176,18 @@ export class CommentView implements IRenderedElement {
this.commentId,
this.workspace,
topBarGroup,
this,
);
const foldoutButton = new CollapseCommentBarButton(
this.commentId,
this.workspace,
topBarGroup,
this,
);
this.addDisposeListener(() => {
deleteButton.dispose();
foldoutButton.dispose();
});
const textPreview = dom.createSvgElement(
Svg.TEXT,
{
@@ -362,7 +368,10 @@ export class CommentView implements IRenderedElement {
const textPreviewWidth =
size.width - foldoutSize.getWidth() - deleteSize.getWidth();
this.textPreview.setAttribute('x', `${foldoutSize.getWidth()}`);
this.textPreview.setAttribute(
'x',
`${(this.workspace.RTL ? -1 : 1) * foldoutSize.getWidth()}`,
);
this.textPreview.setAttribute(
'y',
`${textPreviewMargin + textPreviewSize.height / 2}`,
@@ -612,13 +621,12 @@ export class CommentView implements IRenderedElement {
/** Disposes of this comment view. */
dispose() {
this.disposing = true;
this.foldoutButton.dispose();
this.deleteButton.dispose();
dom.removeNode(this.svgRoot);
// Loop through listeners backwards in case they remove themselves.
for (let i = this.disposeListeners.length - 1; i >= 0; i--) {
this.disposeListeners[i]();
}
this.disposeListeners.length = 0;
this.disposed = true;
}

View File

@@ -11,6 +11,7 @@ import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {CommentBarButton} from './comment_bar_button.js';
import type {CommentView} from './comment_view.js';
/**
* Magic string appended to the comment ID to create a unique ID for this button.
@@ -42,8 +43,9 @@ export class DeleteCommentBarButton extends CommentBarButton {
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
protected readonly commentView: CommentView,
) {
super(id, workspace, container);
super(id, workspace, container, commentView);
this.icon = dom.createSvgElement(
Svg.IMAGE,
@@ -97,7 +99,7 @@ export class DeleteCommentBarButton extends CommentBarButton {
return;
}
this.getParentComment().dispose();
this.getCommentView().dispose();
e?.stopPropagation();
getFocusManager().focusNode(this.workspace);
}

View File

@@ -74,15 +74,6 @@ export class RenderedWorkspaceComment
this,
this.startGesture,
);
// Don't zoom with mousewheel; let it scroll instead.
browserEvents.conditionalBind(
this.view.getSvgRoot(),
'wheel',
this,
(e: Event) => {
e.stopPropagation();
},
);
}
/**
@@ -289,6 +280,7 @@ export class RenderedWorkspaceComment
paster: WorkspaceCommentPaster.TYPE,
commentState: commentSerialization.save(this, {
addCoordinates: true,
saveIds: false,
}),
};
}
@@ -355,6 +347,7 @@ export class RenderedWorkspaceComment
this.select();
// Ensure that the comment is always at the top when focused.
this.workspace.getLayerManager()?.append(this, layers.BLOCK);
this.workspace.scrollBoundsIntoView(this.getBoundingRectangle());
}
/** See IFocusableNode.onNodeBlur. */

View File

@@ -23,8 +23,15 @@ import {CommentIcon} from './icons/comment_icon.js';
import {Msg} from './msg.js';
import {StatementInput} from './renderers/zelos/zelos.js';
import {Coordinate} from './utils/coordinate.js';
import * as svgMath from './utils/svg_math.js';
import type {WorkspaceSvg} from './workspace_svg.js';
function isFullBlockField(block?: BlockSvg) {
if (!block || !block.isSimpleReporter()) return false;
const firstField = block.getFields().next().value;
return firstField?.isFullBlockField();
}
/**
* Option to undo previous action.
*/
@@ -362,10 +369,15 @@ export function registerComment() {
preconditionFn(scope: Scope) {
const block = scope.block;
if (
!block!.isInFlyout &&
block!.workspace.options.comments &&
!block!.isCollapsed() &&
block!.isEditable()
block &&
!block.isInFlyout &&
block.workspace.options.comments &&
!block.isCollapsed() &&
block.isEditable() &&
// Either block already has a comment so let us remove it,
// or the block isn't just one full-block field block, which
// shouldn't be allowed to have comments as there's no way to read them.
(block.hasIcon(CommentIcon.TYPE) || !isFullBlockField(block))
) {
return 'enabled';
}
@@ -373,8 +385,8 @@ export function registerComment() {
},
callback(scope: Scope) {
const block = scope.block;
if (block!.hasIcon(CommentIcon.TYPE)) {
block!.setCommentText(null);
if (block && block.hasIcon(CommentIcon.TYPE)) {
block.setCommentText(null);
} else {
block!.setCommentText('');
}
@@ -626,9 +638,9 @@ export function registerCommentCreate() {
const comment = new RenderedWorkspaceComment(workspace);
comment.setPlaceholderText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']);
comment.moveTo(
pixelsToWorkspaceCoords(
new Coordinate(location.x, location.y),
svgMath.screenToWsCoordinates(
workspace,
new Coordinate(location.x, location.y),
),
);
getFocusManager().focusNode(comment);
@@ -641,40 +653,6 @@ export function registerCommentCreate() {
ContextMenuRegistry.registry.register(createOption);
}
/**
* Converts pixel coordinates (relative to the window) to workspace coordinates.
*/
function pixelsToWorkspaceCoords(
pixelCoord: Coordinate,
workspace: WorkspaceSvg,
): Coordinate {
const injectionDiv = workspace.getInjectionDiv();
// Bounding rect coordinates are in client coordinates, meaning that they
// are in pixels relative to the upper left corner of the visible browser
// window. These coordinates change when you scroll the browser window.
const boundingRect = injectionDiv.getBoundingClientRect();
// The client coordinates offset by the injection div's upper left corner.
const clientOffsetPixels = new Coordinate(
pixelCoord.x - boundingRect.left,
pixelCoord.y - boundingRect.top,
);
// The offset in pixels between the main workspace's origin and the upper
// left corner of the injection div.
const mainOffsetPixels = workspace.getOriginOffsetInPixels();
// The position of the new comment in pixels relative to the origin of the
// main workspace.
const finalOffset = Coordinate.difference(
clientOffsetPixels,
mainOffsetPixels,
);
// The position of the new comment in main workspace coordinates.
finalOffset.scale(1 / workspace.scale);
return finalOffset;
}
/** Registers all block-scoped context menu items. */
function registerBlockOptions_() {
registerDuplicate();

View File

@@ -181,7 +181,8 @@ let content = `
cursor: -webkit-grabbing;
}
.blocklyDragging.blocklyDraggingDelete {
.blocklyDragging.blocklyDraggingDelete,
.blocklyDragging.blocklyDraggingDelete .blocklyField {
cursor: url("<<<PATH>>>/handdelete.cur"), auto;
}
@@ -241,7 +242,7 @@ let content = `
cursor: default;
}
.blocklyIconGroup:not(:hover),
.blocklyIconGroup:not(:hover):not(:focus),
.blocklyIconGroupReadonly {
opacity: .6;
}

View File

@@ -119,9 +119,6 @@ export abstract class Field<T = any>
return this.size;
}
/**
* Sets the size of this field.
*/
protected set size_(newValue: Size) {
this.size = newValue;
}
@@ -852,8 +849,7 @@ export abstract class Field<T = any>
totalHeight = Math.max(totalHeight, constants!.FIELD_BORDER_RECT_HEIGHT);
}
this.size_.height = totalHeight;
this.size_.width = totalWidth;
this.size_ = new Size(totalWidth, totalHeight);
this.positionTextElement_(xOffset, contentWidth);
this.positionBorderRect_();
@@ -1384,7 +1380,12 @@ export abstract class Field<T = any>
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
onNodeFocus(): void {
const block = this.getSourceBlock() as BlockSvg;
block.workspace.scrollBoundsIntoView(
block.getBoundingRectangleWithoutChildren(),
);
}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}

View File

@@ -29,6 +29,7 @@ import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import {Size} from './utils/size.js';
import * as utilsString from './utils/string.js';
import {Svg} from './utils/svg.js';
@@ -553,8 +554,7 @@ export class FieldDropdown extends Field<string> {
} else {
arrowWidth = dom.getTextWidth(this.arrow as SVGTSpanElement);
}
this.size_.width = imageWidth + arrowWidth + xPadding * 2;
this.size_.height = height;
this.size_ = new Size(imageWidth + arrowWidth + xPadding * 2, height);
let arrowX = 0;
if (block.RTL) {
@@ -595,8 +595,7 @@ export class FieldDropdown extends Field<string> {
height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2,
);
}
this.size_.width = textWidth + arrowWidth + xPadding * 2;
this.size_.height = height;
this.size_ = new Size(textWidth + arrowWidth + xPadding * 2, height);
this.positionTextElement_(xPadding, textWidth);
}
@@ -699,25 +698,30 @@ export class FieldDropdown extends Field<string> {
prefix?: string;
suffix?: string;
} {
let hasImages = false;
let hasNonTextContent = false;
const trimmedOptions = options.map((option): MenuOption => {
if (option === FieldDropdown.SEPARATOR) return option;
if (option === FieldDropdown.SEPARATOR) {
hasNonTextContent = true;
return option;
}
const [label, value] = option;
if (typeof label === 'string') {
return [parsing.replaceMessageReferences(label), value];
}
hasImages = true;
hasNonTextContent = true;
// Copy the image properties so they're not influenced by the original.
// NOTE: No need to deep copy since image properties are only 1 level deep.
const imageLabel = isImageProperties(label)
? {...label, alt: parsing.replaceMessageReferences(label.alt)}
: {...label};
: label;
return [imageLabel, value];
});
if (hasImages || options.length < 2) return {options: trimmedOptions};
if (hasNonTextContent || options.length < 2) {
return {options: trimmedOptions};
}
const stringOptions = trimmedOptions as [string, string][];
const stringLabels = stringOptions.map(([label]) => label);
@@ -793,7 +797,7 @@ export class FieldDropdown extends Field<string> {
} else if (typeof option[1] !== 'string') {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
Found ${option[1]} in: ${option}`,
);
} else if (
@@ -806,7 +810,7 @@ export class FieldDropdown extends Field<string> {
) {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option must have a string
`Invalid option[${i}]: Each FieldDropdown option must have a string
label, image description, or HTML element. Found ${option[0]} in: ${option}`,
);
}

View File

@@ -45,6 +45,11 @@ import type {WorkspaceSvg} from './workspace_svg.js';
*/
type InputTypes = string | number;
/**
* The minimum width of an input field.
*/
const MINIMUM_WIDTH = 14;
/**
* Abstract class for an editable input field.
*
@@ -102,11 +107,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
*/
override SERIALIZABLE = true;
/**
* Sets the size of this field. Although this appears to be a no-op, it must
* exist since the getter is overridden below.
*/
protected override set size_(newValue: Size) {
// Although this appears to be a no-op, it must exist since the getter is
// overridden below.
super.size_ = newValue;
}
@@ -115,8 +118,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
*/
protected override get size_() {
const s = super.size_;
if (s.width < 14) {
s.width = 14;
if (s.width < MINIMUM_WIDTH) {
s.width = MINIMUM_WIDTH;
}
return s;
@@ -732,6 +735,23 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
return true;
}
/**
* Position a field's text element after a size change. This handles both LTR
* and RTL positioning.
*
* @param xMargin x offset to use when positioning the text element.
* @param contentWidth The content width.
*/
protected override positionTextElement_(
xMargin: number,
contentWidth: number,
) {
const effectiveWidth = xMargin * 2 + contentWidth;
const delta =
effectiveWidth < MINIMUM_WIDTH ? (MINIMUM_WIDTH - effectiveWidth) / 2 : 0;
super.positionTextElement_(xMargin + delta, contentWidth);
}
/**
* Use the `getText_` developer hook to override the field's text
* representation. When we're currently editing, return the current HTML value

View File

@@ -56,11 +56,11 @@ export interface RegistrableField {
* @param type The field type name as used in the JSON definition.
* @param fieldClass The field class containing a fromJson function that can
* construct an instance of the field.
* @throws {Error} if the type name is empty, the field is already registered,
* or the fieldClass is not an object containing a fromJson function.
* @throws {Error} if the type name is empty or the fieldClass is not an object
* containing a fromJson function.
*/
export function register(type: string, fieldClass: RegistrableField) {
registry.register(registry.Type.FIELD, type, fieldClass);
registry.register(registry.Type.FIELD, type, fieldClass, true);
}
/**

View File

@@ -398,7 +398,11 @@ export class FlyoutButton
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
onNodeFocus(): void {
const xy = this.getPosition();
const bounds = new Rect(xy.y, xy.y + this.height, xy.x, xy.x + this.width);
this.workspace.scrollBoundsIntoView(bounds);
}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}

View File

@@ -309,6 +309,8 @@ export class FocusManager {
* Note that this may update the specified node's element's tabindex to ensure
* that it can be properly read out by screenreaders while focused.
*
* The focused node will not be automatically scrolled into view.
*
* @param focusableNode The node that should receive active focus.
*/
focusNode(focusableNode: IFocusableNode): void {
@@ -423,6 +425,8 @@ export class FocusManager {
* the returned lambda is called. Additionally, only 1 ephemeral focus context
* can be active at any given time (attempting to activate more than one
* simultaneously will result in an error being thrown).
*
* This method does not scroll the ephemerally focused element into view.
*/
takeEphemeralFocus(
focusableElement: HTMLElement | SVGElement,
@@ -439,7 +443,7 @@ export class FocusManager {
if (this.focusedNode) {
this.passivelyFocusNode(this.focusedNode, null);
}
focusableElement.focus();
focusableElement.focus({preventScroll: true});
let hasFinishedEphemeralFocus = false;
return () => {
@@ -574,7 +578,7 @@ export class FocusManager {
}
this.setNodeToVisualActiveFocus(node);
elem.focus();
elem.focus({preventScroll: true});
}
/**

View File

@@ -11,7 +11,6 @@ import type {BlockSvg} from '../block_svg.js';
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import type {ISerializable} from '../interfaces/i_serializable.js';
import * as renderManagement from '../render_management.js';
@@ -62,7 +61,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
/**
* The visibility of the bubble for this comment.
*
* This is used to track what the visibile state /should/ be, not necessarily
* This is used to track what the visible state /should/ be, not necessarily
* what it currently /is/. E.g. sometimes this will be true, but the block
* hasn't been rendered yet, so the bubble will not currently be visible.
*/
@@ -340,7 +339,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
}
/** See IHasBubble.getBubble. */
getBubble(): IBubble | null {
getBubble(): TextInputBubble | null {
return this.textInputBubble;
}
@@ -365,6 +364,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
this.sourceBlock.workspace as WorkspaceSvg,
this.getAnchorLocation(),
this.getBubbleOwnerRect(),
this,
);
this.textInputBubble.setText(this.getText());
this.textInputBubble.setSize(this.bubbleSize, true);

View File

@@ -14,6 +14,7 @@ import * as tooltip from '../tooltip.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as idGenerator from '../utils/idgenerator.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
@@ -168,7 +169,16 @@ export abstract class Icon implements IIcon {
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
onNodeFocus(): void {
const blockBounds = (this.sourceBlock as BlockSvg).getBoundingRectangle();
const bounds = new Rect(
blockBounds.top + this.offsetInBlock.y,
blockBounds.top + this.offsetInBlock.y + this.getSize().height,
blockBounds.left + this.offsetInBlock.x,
blockBounds.left + this.offsetInBlock.x + this.getSize().width,
);
(this.sourceBlock as BlockSvg).workspace.scrollBoundsIntoView(bounds);
}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}

View File

@@ -150,8 +150,17 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
return markerConn;
}
private createInsertionMarker(origBlock: BlockSvg) {
const blockJson = blocks.save(origBlock, {
/**
* Transforms the given block into a JSON representation used to construct an
* insertion marker.
*
* @param block The block to serialize and use as an insertion marker.
* @returns A JSON-formatted string corresponding to a serialized
* representation of the given block suitable for use as an insertion
* marker.
*/
protected serializeBlockToInsertionMarker(block: BlockSvg) {
const blockJson = blocks.save(block, {
addCoordinates: false,
addInputBlocks: false,
addNextBlocks: false,
@@ -160,10 +169,15 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
if (!blockJson) {
throw new Error(
`Failed to serialize source block. ${origBlock.toDevString()}`,
`Failed to serialize source block. ${block.toDevString()}`,
);
}
return blockJson;
}
private createInsertionMarker(origBlock: BlockSvg) {
const blockJson = this.serializeBlockToInsertionMarker(origBlock);
const result = blocks.append(blockJson, this.workspace) as BlockSvg;
// Turn shadow blocks that are created programmatically during

View File

@@ -59,6 +59,9 @@ export interface IFocusableNode {
* they should avoid the following:
* - Creating or removing DOM elements (including via the renderer or drawer).
* - Affecting focus via DOM focus() calls or the FocusManager.
*
* Implementations may consider scrolling themselves into view here; that is
* not handled by the focus manager.
*/
onNodeFocus(): void;

View File

@@ -43,7 +43,7 @@ export interface IVariableMap<T extends IVariableModel<IVariableState>> {
* Creates a new variable with the given name. If ID is not specified, the
* variable map should create one. Returns the new variable.
*/
createVariable(name: string, id?: string, type?: string | null): T;
createVariable(name: string, type?: string, id?: string | null): T;
/* Adds a variable to this variable map. */
addVariable(variable: T): void;

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from an TextInputBubble.
*/
export class BlockCommentNavigationPolicy
implements INavigationPolicy<TextInputBubble>
{
/**
* Returns the first child of the given block comment.
*
* @param current The block comment to return the first child of.
* @returns The text editor of the given block comment bubble.
*/
getFirstChild(current: TextInputBubble): IFocusableNode | null {
return current.getEditor();
}
/**
* Returns the parent of the given block comment.
*
* @param current The block comment to return the parent of.
* @returns The parent block of the given block comment.
*/
getParent(current: TextInputBubble): IFocusableNode | null {
return current.getOwner() ?? null;
}
/**
* Returns the next peer node of the given block comment.
*
* @param _current The block comment to find the following element of.
* @returns Null.
*/
getNextSibling(_current: TextInputBubble): IFocusableNode | null {
return null;
}
/**
* Returns the previous peer node of the given block comment.
*
* @param _current The block comment to find the preceding element of.
* @returns Null.
*/
getPreviousSibling(_current: TextInputBubble): IFocusableNode | null {
return null;
}
/**
* Returns whether or not the given block comment can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given block comment can be focused.
*/
isNavigable(current: TextInputBubble): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is an TextInputBubble.
*/
isApplicable(current: any): current is TextInputBubble {
return current instanceof TextInputBubble;
}
}

View File

@@ -31,7 +31,9 @@ export class CommentBarButtonNavigationPolicy
* @returns The parent comment of the given CommentBarButton.
*/
getParent(current: CommentBarButton): IFocusableNode | null {
return current.getParentComment();
return current
.getCommentView()
.workspace.getCommentById(current.getCommentView().commentId);
}
/**
@@ -41,7 +43,7 @@ export class CommentBarButtonNavigationPolicy
* @returns The next CommentBarButton, if any.
*/
getNextSibling(current: CommentBarButton): IFocusableNode | null {
const children = current.getParentComment().view.getCommentBarButtons();
const children = current.getCommentView().getCommentBarButtons();
const currentIndex = children.indexOf(current);
if (currentIndex >= 0 && currentIndex + 1 < children.length) {
return children[currentIndex + 1];
@@ -56,7 +58,7 @@ export class CommentBarButtonNavigationPolicy
* @returns The CommentBarButton's previous CommentBarButton, if any.
*/
getPreviousSibling(current: CommentBarButton): IFocusableNode | null {
const children = current.getParentComment().view.getCommentBarButtons();
const children = current.getCommentView().getCommentBarButtons();
const currentIndex = children.indexOf(current);
if (currentIndex > 0) {
return children[currentIndex - 1];

View File

@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentEditor} from '../comments/comment_editor.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from a comment editor.
* This is a no-op placeholder (other than isNavigable/isApplicable) since
* comment editors handle their own navigation when editing ends.
*/
export class CommentEditorNavigationPolicy
implements INavigationPolicy<CommentEditor>
{
getFirstChild(_current: CommentEditor): IFocusableNode | null {
return null;
}
getParent(_current: CommentEditor): IFocusableNode | null {
return null;
}
getNextSibling(_current: CommentEditor): IFocusableNode | null {
return null;
}
getPreviousSibling(_current: CommentEditor): IFocusableNode | null {
return null;
}
/**
* Returns whether or not the given comment editor can be navigated to.
*
* @param current The instance to check for navigability.
* @returns False.
*/
isNavigable(current: CommentEditor): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a CommentEditor.
*/
isApplicable(current: any): current is CommentEditor {
return current instanceof CommentEditor;
}
}

View File

@@ -6,6 +6,7 @@
import {BlockSvg} from '../block_svg.js';
import {getFocusManager} from '../focus_manager.js';
import {CommentIcon} from '../icons/comment_icon.js';
import {Icon} from '../icons/icon.js';
import {MutatorIcon} from '../icons/mutator_icon.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
@@ -29,6 +30,12 @@ export class IconNavigationPolicy implements INavigationPolicy<Icon> {
getFocusManager().getFocusedNode() === current
) {
return current.getBubble()?.getWorkspace() ?? null;
} else if (
current instanceof CommentIcon &&
current.bubbleIsVisible() &&
getFocusManager().getFocusedNode() === current
) {
return current.getBubble()?.getEditor() ?? null;
}
return null;

View File

@@ -15,11 +15,10 @@
import {BlockSvg} from '../block_svg.js';
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
import {Field} from '../field.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {Marker} from './marker.js';
/**
@@ -390,20 +389,6 @@ export class LineCursor extends Marker {
*/
setCurNode(newNode: IFocusableNode) {
getFocusManager().focusNode(newNode);
// Try to scroll cursor into view.
if (newNode instanceof BlockSvg) {
newNode.workspace.scrollBoundsIntoView(
newNode.getBoundingRectangleWithoutChildren(),
);
} else if (newNode instanceof Field) {
const block = newNode.getSourceBlock() as BlockSvg;
block.workspace.scrollBoundsIntoView(
block.getBoundingRectangleWithoutChildren(),
);
} else if (newNode instanceof RenderedWorkspaceComment) {
newNode.workspace.scrollBoundsIntoView(newNode.getBoundingRectangle());
}
}
/**

View File

@@ -6,8 +6,10 @@
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {INavigationPolicy} from './interfaces/i_navigation_policy.js';
import {BlockCommentNavigationPolicy} from './keyboard_nav/block_comment_navigation_policy.js';
import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js';
import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js';
import {CommentEditorNavigationPolicy} from './keyboard_nav/comment_editor_navigation_policy.js';
import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js';
import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js';
import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js';
@@ -33,6 +35,8 @@ export class Navigator {
new IconNavigationPolicy(),
new WorkspaceCommentNavigationPolicy(),
new CommentBarButtonNavigationPolicy(),
new BlockCommentNavigationPolicy(),
new CommentEditorNavigationPolicy(),
];
/**

View File

@@ -119,9 +119,9 @@ export class Type<_T> {
/** @internal */
static PASTER = new Type<IPaster<ICopyData, ICopyable<ICopyData>>>('paster');
static VARIABLE_MODEL = new Type<IVariableModelStatic<IVariableState>>(
'variableModel',
);
static VARIABLE_MODEL = new Type<
IVariableModelStatic<IVariableState> & IVariableModel<IVariableState>
>('variableModel');
static VARIABLE_MAP = new Type<IVariableMap<IVariableModel<IVariableState>>>(
'variableMap',

View File

@@ -644,6 +644,9 @@ export class RenderedConnection
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.highlight();
this.getSourceBlock().workspace.scrollBoundsIntoView(
this.getSourceBlock().getBoundingRectangleWithoutChildren(),
);
}
/** See IFocusableNode.onNodeBlur. */
@@ -656,12 +659,12 @@ export class RenderedConnection
return true;
}
private findHighlightSvg(): SVGElement | null {
private findHighlightSvg(): SVGPathElement | null {
// This cast is valid as TypeScript's definition is wrong. See:
// https://github.com/microsoft/TypeScript/issues/60996.
return document.getElementById(this.id) as
| unknown
| null as SVGElement | null;
| null as SVGPathElement | null;
}
}

View File

@@ -122,9 +122,12 @@ export class Drawer {
} else if (Types.isSpacer(elem)) {
this.outlinePath_ += svgPaths.lineOnAxis('h', elem.width);
}
// No branch for a square corner, because it's a no-op.
}
// No branch for a square corner, because it's a no-op.
this.outlinePath_ += svgPaths.lineOnAxis('v', topRow.height);
this.outlinePath_ += svgPaths.lineOnAxis(
'v',
topRow.height - topRow.ascenderHeight,
);
}
/**

View File

@@ -22,10 +22,7 @@ import '../events/events_toolbox_item_select.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import {getFocusManager} from '../focus_manager.js';
import {
isAutoHideable,
type IAutoHideable,
} from '../interfaces/i_autohideable.js';
import {type IAutoHideable} from '../interfaces/i_autohideable.js';
import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js';
import {isDeletable} from '../interfaces/i_deletable.js';
import type {IDraggable} from '../interfaces/i_draggable.js';
@@ -1150,10 +1147,7 @@ export class Toolbox
// If navigating to anything other than the toolbox's flyout then clear the
// selection so that the toolbox's flyout can automatically close.
if (!nextTree || nextTree !== this.flyout?.getWorkspace()) {
this.clearSelection();
if (this.flyout && isAutoHideable(this.flyout)) {
this.flyout.autoHide(false);
}
this.autoHide(false);
}
}
}

View File

@@ -46,6 +46,7 @@ export const TOUCH_MAP: {[key: string]: string[]} = {
'mouseup': ['pointerup', 'pointercancel'],
'touchend': ['pointerup'],
'touchcancel': ['pointercancel'],
'pointerup': ['pointerup', 'pointercancel'],
};
/** PID of queued long-press task. */

View File

@@ -109,6 +109,9 @@ export class VariableMap
variable: IVariableModel<IVariableState>,
newType: string,
): IVariableModel<IVariableState> {
const oldType = variable.getType();
if (oldType === newType) return variable;
this.variableMap.get(variable.getType())?.delete(variable.getId());
variable.setType(newType);
const newTypeVariables =
@@ -118,6 +121,13 @@ export class VariableMap
if (!this.variableMap.has(newType)) {
this.variableMap.set(newType, newTypeVariables);
}
eventUtils.fire(
new (eventUtils.get(EventType.VAR_TYPE_CHANGE))(
variable,
oldType,
newType,
),
);
return variable;
}
@@ -245,9 +255,9 @@ export class VariableMap
}
const id = opt_id || idGenerator.genUid();
const type = opt_type || '';
const VariableModel = registry.getObject(
const VariableModel = registry.getClassFromOptions(
registry.Type.VARIABLE_MODEL,
registry.DEFAULT,
this.workspace.options,
true,
);
if (!VariableModel) {

View File

@@ -469,7 +469,7 @@ export class Workspace {
'Blockly.Workspace.getVariableUsesById',
'v12',
'v13',
'Blockly.Workspace.getVariableMap().getVariableUsesById',
'Blockly.Variables.getVariableUsesById',
);
return getVariableUsesById(this, id);
}

View File

@@ -22,6 +22,7 @@ import type {Block} from './block.js';
import type {BlockSvg} from './block_svg.js';
import type {BlocklyOptions} from './blockly_options.js';
import * as browserEvents from './browser_events.js';
import {TextInputBubble} from './bubbles/textinput_bubble.js';
import {COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/collapse_comment_bar_button.js';
import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js';
import {COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/delete_comment_bar_button.js';
@@ -1673,7 +1674,10 @@ export class WorkspaceSvg
/** Clean up the workspace by ordering all the blocks in a column such that none overlap. */
cleanUp() {
this.setResizesEnabled(false);
eventUtils.setGroup(true);
const existingGroup = eventUtils.getGroup();
if (!existingGroup) {
eventUtils.setGroup(true);
}
const topBlocks = this.getTopBlocks(true);
const movableBlocks = topBlocks.filter((block) => block.isMovable());
@@ -1721,7 +1725,7 @@ export class WorkspaceSvg
block.getHeightWidth().height +
minBlockHeight;
}
eventUtils.setGroup(false);
eventUtils.setGroup(existingGroup);
this.setResizesEnabled(true);
}
@@ -2726,6 +2730,19 @@ export class WorkspaceSvg
previousNode: IFocusableNode | null,
): IFocusableNode | null {
if (!previousNode) {
const flyout = this.targetWorkspace?.getFlyout();
if (this.isFlyout && flyout) {
// Return the first focusable item of the flyout.
return (
flyout
.getContents()
.find((flyoutItem) => {
const element = flyoutItem.getElement();
return isFocusableNode(element) && element.canBeFocused();
})
?.getElement() ?? null
);
}
return this.getTopBlocks(true)[0] ?? null;
} else return null;
}
@@ -2868,6 +2885,11 @@ export class WorkspaceSvg
bubble.getFocusableElement().id === id
) {
return bubble;
} else if (
bubble instanceof TextInputBubble &&
bubble.getEditor().getFocusableElement().id === id
) {
return bubble.getEditor();
}
}
}
@@ -2889,11 +2911,9 @@ export class WorkspaceSvg
// Only hide the flyout if the flyout's workspace is losing focus and that
// focus isn't returning to the flyout itself, the toolbox, or ephemeral.
if (getFocusManager().ephemeralFocusTaken()) return;
const flyout = this.targetWorkspace.getFlyout();
const toolbox = this.targetWorkspace.getToolbox();
if (toolbox && nextTree === toolbox) return;
if (toolbox) toolbox.clearSelection();
if (flyout && isAutoHideable(flyout)) flyout.autoHide(false);
if (isAutoHideable(toolbox)) toolbox.autoHide(false);
}
}

View File

@@ -68,7 +68,7 @@ export function saveWorkspaceComment(
if (!skipId) elem.setAttribute('id', comment.id);
const workspace = comment.workspace;
const loc = comment.getRelativeToSurfaceXY();
const loc = comment.getRelativeToSurfaceXY().clone();
loc.x = workspace.RTL ? workspace.getWidth() - loc.x : loc.x;
elem.setAttribute('x', `${loc.x}`);
elem.setAttribute('y', `${loc.y}`);

View File

@@ -1,6 +1,7 @@
import eslint from '@eslint/js';
import googleStyle from 'eslint-config-google';
import jsdoc from 'eslint-plugin-jsdoc';
import mochaPlugin from 'eslint-plugin-mocha';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
@@ -88,7 +89,8 @@ function buildTSOverride({files, tsconfig}) {
'@typescript-eslint/no-explicit-any': ['off'],
// We use this pattern extensively for block (e.g. controls_if) interfaces.
'@typescript-eslint/no-empty-object-type': ['off'],
// TSDoc doesn't support @yields, so don't require that we use it.
'jsdoc/require-yields': ['off'],
// params and returns docs are optional.
'jsdoc/require-param-description': ['off'],
'jsdoc/require-returns': ['off'],
@@ -200,6 +202,9 @@ export default [
},
{
files: ['tests/**'],
plugins: {
mocha: mochaPlugin,
},
languageOptions: {
globals: {
'Blockly': true,
@@ -219,6 +224,7 @@ export default [
'jsdoc/check-tag-names': ['warn', {'definedTags': ['record']}],
'jsdoc/tag-lines': ['off'],
'jsdoc/no-defaults': ['off'],
'mocha/no-exclusive-tests': 'error',
},
},
{

View File

@@ -45,7 +45,11 @@ import {
publishBeta,
recompile,
} from './scripts/gulpfiles/release_tasks.mjs';
import {generators, test} from './scripts/gulpfiles/test_tasks.mjs';
import {
generators,
interactiveMocha,
test,
} from './scripts/gulpfiles/test_tasks.mjs';
const clean = parallel(cleanBuildDir, cleanReleaseDir);
@@ -80,6 +84,7 @@ export {
clean,
test,
generators as testGenerators,
interactiveMocha,
buildAdvancedCompilationTest,
createRC as gitCreateRC,
docs,

714
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "blockly",
"version": "12.2.0",
"version": "12.3.0",
"description": "Blockly is a library for building visual programming editors.",
"keywords": [
"blockly"
@@ -45,7 +45,7 @@
"test": "gulp test",
"test:browser": "cd tests/browser && npx mocha",
"test:generators": "gulp testGenerators",
"test:mocha:interactive": "npm run build && concurrently -n tsc,server \"tsc --watch --preserveWatchOutput --outDir \"build/src\" --declarationDir \"build/declarations\"\" \"http-server ./ -o /tests/mocha/index.html -c-1\"",
"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",
"updateGithubPages": "npm ci && gulp gitUpdateGithubPages"
},
@@ -100,24 +100,26 @@
},
"license": "Apache-2.0",
"devDependencies": {
"@blockly/block-test": "^7.0.1",
"@blockly/dev-tools": "^9.0.0",
"@blockly/theme-modern": "^6.0.3",
"@blockly/block-test": "^7.0.2",
"@blockly/dev-tools": "^9.0.2",
"@blockly/theme-modern": "^7.0.1",
"@hyperjump/browser": "^1.1.4",
"@hyperjump/json-schema": "^1.5.0",
"@microsoft/api-documenter": "^7.22.4",
"@microsoft/api-documenter": "7.22.4",
"@microsoft/api-extractor": "^7.29.5",
"ajv": "^8.17.1",
"async-done": "^2.0.0",
"chai": "^5.1.1",
"chai": "^6.0.1",
"concurrently": "^9.0.1",
"eslint": "^9.15.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-jsdoc": "^51.3.1",
"eslint-plugin-jsdoc": "^52.0.2",
"eslint-plugin-mocha": "^11.1.0",
"eslint-plugin-prettier": "^5.2.1",
"glob": "^11.0.1",
"globals": "^16.0.0",
"google-closure-compiler": "^20250625.0.0",
"google-closure-compiler": "^20250709.0.0",
"gulp": "^5.0.0",
"gulp-concat": "^2.6.1",
"gulp-gzip": "^1.4.2",
@@ -136,6 +138,7 @@
"patch-package": "^8.0.0",
"prettier": "^3.3.3",
"prettier-plugin-organize-imports": "^4.0.0",
"puppeteer-core": "^24.17.0",
"readline-sync": "^1.4.10",
"rimraf": "^5.0.0",
"typescript": "^5.3.3",

View File

@@ -1,8 +1,8 @@
diff --git a/node_modules/@microsoft/api-documenter/lib/documenters/MarkdownDocumenter.js b/node_modules/@microsoft/api-documenter/lib/documenters/MarkdownDocumenter.js
index 0f4e2ba..3af2014 100644
index 5284d10..4f8b439 100644
--- a/node_modules/@microsoft/api-documenter/lib/documenters/MarkdownDocumenter.js
+++ b/node_modules/@microsoft/api-documenter/lib/documenters/MarkdownDocumenter.js
@@ -893,12 +893,15 @@ class MarkdownDocumenter {
@@ -877,12 +877,14 @@ class MarkdownDocumenter {
}
_writeBreadcrumb(output, apiItem) {
const configuration = this._tsdocConfiguration;
@@ -19,28 +19,23 @@ index 0f4e2ba..3af2014 100644
+ // linkText: 'Home',
+ // urlDestination: this._getLinkFilenameForApiItem(this._apiModel)
+ // }));
+
+ let first = true;
for (const hierarchyItem of apiItem.getHierarchy()) {
switch (hierarchyItem.kind) {
case api_extractor_model_1.ApiItemKind.Model:
@@ -908,18 +911,24 @@ class MarkdownDocumenter {
@@ -892,18 +894,23 @@ class MarkdownDocumenter {
// this may change in the future.
break;
default:
- output.appendNodesInParagraph([
- new tsdoc_1.DocPlainText({
- configuration,
- text: ' > '
- }),
+ if (!first) {
+ // Only print the breadcrumb separator if it's not the first item we're printing.
+ output.appendNodeInParagraph(
+ new tsdoc_1.DocPlainText({
+ configuration,
+ text: ' > '
+ })
+ );
new tsdoc_1.DocPlainText({
configuration,
text: ' > '
- }),
+ }));
+ }
+ first = false;
+ output.appendNodeInParagraph(
@@ -55,7 +50,7 @@ index 0f4e2ba..3af2014 100644
}
}
}
@@ -992,11 +1001,8 @@ class MarkdownDocumenter {
@@ -968,11 +975,8 @@ class MarkdownDocumenter {
// For overloaded methods, add a suffix such as "MyClass.myMethod_2".
let qualifiedName = Utilities_1.Utilities.getSafeFilenameForName(hierarchyItem.displayName);
if (api_extractor_model_1.ApiParameterListMixin.isBaseClassOf(hierarchyItem)) {
@@ -69,7 +64,7 @@ index 0f4e2ba..3af2014 100644
}
switch (hierarchyItem.kind) {
case api_extractor_model_1.ApiItemKind.Model:
@@ -1007,7 +1013,8 @@ class MarkdownDocumenter {
@@ -983,7 +987,8 @@ class MarkdownDocumenter {
baseName = Utilities_1.Utilities.getSafeFilenameForName(node_core_library_1.PackageName.getUnscopedName(hierarchyItem.displayName));
break;
default:

View File

@@ -2,8 +2,8 @@ import {execSync} from 'child_process';
import {Extractor} from 'markdown-tables-to-json';
import * as fs from 'fs';
import * as gulp from 'gulp';
import * as header from 'gulp-header';
import * as replace from 'gulp-replace';
import header from 'gulp-header';
import replace from 'gulp-replace';
const DOCS_DIR = 'docs';

View File

@@ -257,9 +257,9 @@ async function metadata() {
* Run Mocha tests inside a browser.
* @return {Promise} Asynchronous result.
*/
async function mocha() {
async function mocha(exitOnCompletion = true) {
return runTestTask('mocha', async () => {
const result = await runMochaTestsInBrowser().catch(e => {
const result = await runMochaTestsInBrowser(exitOnCompletion).catch(e => {
throw e;
});
if (result) {
@@ -269,6 +269,14 @@ async function mocha() {
});
}
/**
* Run Mocha tests inside a browser and keep the browser open upon completion.
* @return {Promise} Asynchronous result.
*/
export async function interactiveMocha() {
return mocha(false);
}
/**
* Helper method for comparison file.
* @param {string} file1 First target file.

View File

@@ -101,6 +101,45 @@ suite('Right Clicking on Blocks', function () {
await contextMenuSelect(this.browser, this.block, 'Remove Comment');
chai.assert.isNull(await getCommentText(this.browser, this.block.id));
});
test('does not scroll the page when node is ephemerally focused', async function () {
const initialScroll = await this.browser.execute(() => {
return window.scrollY;
});
// This left-right-left sequence was necessary to reproduce unintended
// scrolling; regardless of the number of clicks/context menu activations,
// the page should not scroll.
this.block.click({button: 2});
this.block.click({button: 0});
this.block.click({button: 2});
await this.browser.pause(250);
const finalScroll = await this.browser.execute(() => {
return window.scrollY;
});
chai.assert.equal(initialScroll, finalScroll);
});
test('does not scroll the page when node is actively focused', async function () {
await this.browser.setWindowSize(500, 300);
await this.browser.setViewport({width: 500, height: 300});
const initialScroll = await this.browser.execute((blockId) => {
window.scrollTo(0, document.body.scrollHeight);
return window.scrollY;
}, this.block.id);
await this.browser.execute(() => {
Blockly.getFocusManager().focusNode(
Blockly.getMainWorkspace().getToolbox(),
);
});
const finalScroll = await this.browser.execute(() => {
return window.scrollY;
});
chai.assert.equal(initialScroll, finalScroll);
await this.browser.setWindowSize(800, 600);
await this.browser.setViewport({width: 800, height: 600});
});
});
suite('Disabling', function () {
@@ -199,3 +238,224 @@ suite('Disabling', function () {
},
);
});
suite('Focused nodes are scrolled into bounds', function () {
// Setting timeout to unlimited as the webdriver takes a longer time to run
// than most mocha tests.
this.timeout(0);
// Setup Selenium for all of the tests
suiteSetup(async function () {
this.browser = await testSetup(testFileLocations.PLAYGROUND);
await this.browser.execute(() => {
window.focusScrollTest = async (testcase) => {
const workspace = Blockly.getMainWorkspace();
const metrics = workspace.getMetricsManager();
const initialViewport = metrics.getViewMetrics(true);
const elementBounds = await testcase(workspace);
await Blockly.renderManagement.finishQueuedRenders();
const scrolledViewport = metrics.getViewMetrics(true);
const workspaceBounds = new Blockly.utils.Rect(
scrolledViewport.top,
scrolledViewport.top + scrolledViewport.height,
scrolledViewport.left,
scrolledViewport.left + scrolledViewport.width,
);
return {
changed:
JSON.stringify(initialViewport) !==
JSON.stringify(scrolledViewport),
intersects: elementBounds.intersects(workspaceBounds),
contains: workspaceBounds.contains(
elementBounds.getOrigin().x,
elementBounds.getOrigin().y,
),
elementBounds,
workspaceBounds,
};
};
});
});
setup(async function () {
await this.browser.execute(() => {
Blockly.serialization.blocks.append(
{
'type': 'text',
'x': -500,
'y': -500,
},
Blockly.getMainWorkspace(),
);
Blockly.serialization.blocks.append(
{
'type': 'controls_if',
'x': 500,
'y': 500,
},
Blockly.getMainWorkspace(),
);
Blockly.getMainWorkspace().zoomCenter(1);
});
});
test('Focused blocks scroll into bounds', async function () {
const result = await this.browser.execute(async () => {
return await window.focusScrollTest(async (workspace) => {
const block = workspace.getTopBlocks()[0];
Blockly.getFocusManager().focusNode(block);
return block.getBoundingRectangleWithoutChildren();
});
});
chai.assert.isTrue(result.intersects);
chai.assert.isTrue(result.contains);
chai.assert.isTrue(result.changed);
});
test('Focused bubbles scroll into bounds', async function () {
const result = await this.browser.execute(async () => {
return await window.focusScrollTest(async (workspace) => {
const block = workspace.getTopBlocks()[0];
block.setCommentText('hello world');
const icon = block.getIcon(Blockly.icons.IconType.COMMENT);
icon.setBubbleVisible(true);
await Blockly.renderManagement.finishQueuedRenders();
icon.setBubbleLocation(new Blockly.utils.Coordinate(-510, -510));
Blockly.getFocusManager().focusNode(icon.getBubble());
const xy = icon.getBubble().getRelativeToSurfaceXY();
const size = icon.getBubble().getSize();
return new Blockly.utils.Rect(
xy.y,
xy.y + size.height,
xy.x,
xy.x + size.width,
);
});
});
chai.assert.isTrue(result.intersects);
chai.assert.isTrue(result.contains);
chai.assert.isTrue(result.changed);
});
test('Comment bar buttons scroll into bounds', async function () {
const result = await this.browser.execute(async () => {
return await window.focusScrollTest(async (workspace) => {
const comment = new Blockly.comments.RenderedWorkspaceComment(
workspace,
);
comment.moveTo(new Blockly.utils.Coordinate(-300, 500));
const commentBarButton = comment.view.getCommentBarButtons()[0];
Blockly.getFocusManager().focusNode(commentBarButton);
const xy = comment.view.getRelativeToSurfaceXY();
const size = comment.view.getSize();
// Comment bar buttons scroll their parent comment view into view.
return new Blockly.utils.Rect(
xy.y,
xy.y + size.height,
xy.x,
xy.x + size.width,
);
});
});
chai.assert.isTrue(result.intersects);
chai.assert.isTrue(result.contains);
chai.assert.isTrue(result.changed);
});
test('Comment editors scroll into bounds', async function () {
const result = await this.browser.execute(async () => {
return await window.focusScrollTest(async (workspace) => {
const comment = new Blockly.comments.RenderedWorkspaceComment(
workspace,
);
comment.moveTo(new Blockly.utils.Coordinate(-300, 500));
const commentEditor = comment.view.getEditorFocusableNode();
Blockly.getFocusManager().focusNode(commentEditor);
// Comment editor bounds can't be calculated externally since they
// depend on private properties, but the comment view is a reasonable
// proxy.
const xy = comment.view.getRelativeToSurfaceXY();
const size = comment.view.getSize();
return new Blockly.utils.Rect(
xy.y,
xy.y + size.height,
xy.x,
xy.x + size.width,
);
});
});
chai.assert.isTrue(result.intersects);
chai.assert.isTrue(result.contains);
chai.assert.isTrue(result.changed);
});
test('Workspace comments scroll into bounds', async function () {
const result = await this.browser.execute(async () => {
return await window.focusScrollTest(async (workspace) => {
const comment = new Blockly.comments.RenderedWorkspaceComment(
workspace,
);
comment.moveTo(new Blockly.utils.Coordinate(-500, 500));
Blockly.getFocusManager().focusNode(comment);
return comment.getBoundingRectangle();
});
});
chai.assert.isTrue(result.intersects);
chai.assert.isTrue(result.contains);
chai.assert.isTrue(result.changed);
});
test('Icons scroll into bounds', async function () {
const result = await this.browser.execute(async () => {
return await window.focusScrollTest(async (workspace) => {
const block = workspace.getTopBlocks()[0];
block.setWarningText('this is bad');
const icon = block.getIcon(Blockly.icons.IconType.WARNING);
Blockly.getFocusManager().focusNode(icon);
// Icon bounds can't be calculated externally since they depend on
// protected properties, but the parent block is a reasonable proxy.
return block.getBoundingRectangleWithoutChildren();
});
});
chai.assert.isTrue(result.intersects);
chai.assert.isTrue(result.contains);
chai.assert.isTrue(result.changed);
});
test('Fields scroll into bounds', async function () {
const result = await this.browser.execute(async () => {
return await window.focusScrollTest(async (workspace) => {
const block = workspace.getTopBlocks()[0];
const field = block.getField('TEXT');
Blockly.getFocusManager().focusNode(field);
// Fields scroll their source block into view.
return block.getBoundingRectangleWithoutChildren();
});
});
chai.assert.isTrue(result.intersects);
chai.assert.isTrue(result.contains);
chai.assert.isTrue(result.changed);
});
test('Connections scroll into bounds', async function () {
const result = await this.browser.execute(async () => {
return await window.focusScrollTest(async (workspace) => {
const block = workspace.getBlocksByType('controls_if')[0];
Blockly.getFocusManager().focusNode(block.nextConnection);
// Connection bounds can't be calculated externally since they depend on
// protected properties, but the parent block is a reasonable proxy.
return block.getBoundingRectangleWithoutChildren();
});
});
chai.assert.isTrue(result.intersects);
chai.assert.isTrue(result.contains);
chai.assert.isTrue(result.changed);
});
});

View File

@@ -0,0 +1,611 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as chai from 'chai';
import {Key} from 'webdriverio';
import {
PAUSE_TIME,
clickWorkspace,
focusOnBlock,
getAllBlocks,
getBlockTypeFromWorkspace,
getCategory,
getSelectedBlockId,
getSelectedBlockType,
openMutatorForBlock,
testFileLocations,
testSetup,
} from './test_setup.mjs';
const testBlockJson = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'controls_repeat_ext',
'id': 'controls_repeat_1',
'x': 88,
'y': 88,
'inputs': {
'TIMES': {
'shadow': {
'type': 'math_number',
'id': 'math_number_shadow_1',
'fields': {
'NUM': 10,
},
},
},
'DO': {
'block': {
'type': 'controls_if',
'id': 'controls_if_1',
'inputs': {
'IF0': {
'block': {
'type': 'logic_boolean',
'id': 'logic_boolean_1',
'fields': {
'BOOL': 'TRUE',
},
},
},
'DO0': {
'block': {
'type': 'text_print',
'id': 'text_print_1',
'inputs': {
'TEXT': {
'shadow': {
'type': 'text',
'id': 'text_shadow_1',
'fields': {
'TEXT': 'abc',
},
},
},
},
},
},
},
},
},
},
},
],
},
};
async function loadStartBlocks(browser) {
await browser.execute((stringifiedJson) => {
// Hangs forever if the json isn't stringified ¯\_(ツ)_/¯
const testBlockJson = JSON.parse(stringifiedJson);
const workspace = Blockly.common.getMainWorkspace();
Blockly.serialization.workspaces.load(testBlockJson, workspace);
}, JSON.stringify(testBlockJson));
await browser.pause(PAUSE_TIME);
}
suite('Clipboard test', async function () {
// Setting timeout to unlimited as these tests take longer time to run
this.timeout(0);
// Clear the workspace and load start blocks
setup(async function () {
this.browser = await testSetup(testFileLocations.PLAYGROUND);
await this.browser.pause(PAUSE_TIME);
});
test('Paste block to/from main workspace', async function () {
await loadStartBlocks(this.browser);
// Select and copy the "true" block
await focusOnBlock(this.browser, 'logic_boolean_1');
await this.browser.pause(PAUSE_TIME);
await this.browser.keys([Key.Ctrl, 'c']);
await this.browser.pause(PAUSE_TIME);
// Check how many blocks there are before pasting
const allBlocksBeforePaste = await getAllBlocks(this.browser);
// Paste the block while still in the main workspace
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check result
const allBlocksAfterPaste = await getAllBlocks(this.browser);
chai.assert.equal(
allBlocksAfterPaste.length,
allBlocksBeforePaste.length + 1,
'Expected there to be one additional block after paste',
);
const focusedBlockId = await getSelectedBlockId(this.browser);
chai.assert.notEqual(
focusedBlockId,
'logic_boolean_1',
'Newly pasted block should be selected',
);
const focusedBlockType = await getSelectedBlockType(this.browser);
chai.assert.equal(
focusedBlockType,
'logic_boolean',
'Newly pasted block should be selected',
);
});
test('Copying a block also copies and pastes its children', async function () {
await loadStartBlocks(this.browser);
// Select and copy the "if/else" block which has children
await focusOnBlock(this.browser, 'controls_if_1');
await this.browser.pause(PAUSE_TIME);
await this.browser.keys([Key.Ctrl, 'c']);
await this.browser.pause(PAUSE_TIME);
// Check how many blocks there are before pasting
const allBlocksBeforePaste = await getAllBlocks(this.browser);
// Paste the block while still in the main workspace
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check result
const allBlocksAfterPaste = await getAllBlocks(this.browser);
chai.assert.equal(
allBlocksAfterPaste.length,
allBlocksBeforePaste.length + 4,
'Expected there to be four additional blocks after paste',
);
});
test('Paste shadow block to/from main workspace', async function () {
await loadStartBlocks(this.browser);
// Select and copy the shadow number block
await focusOnBlock(this.browser, 'math_number_shadow_1');
await this.browser.pause(PAUSE_TIME);
await this.browser.keys([Key.Ctrl, 'c']);
await this.browser.pause(PAUSE_TIME);
// Check how many blocks there are before pasting
const allBlocksBeforePaste = await getAllBlocks(this.browser);
// Paste the block while still in the main workspace
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check result
const allBlocksAfterPaste = await getAllBlocks(this.browser);
chai.assert.equal(
allBlocksAfterPaste.length,
allBlocksBeforePaste.length + 1,
'Expected there to be one additional block after paste',
);
const focusedBlockId = await getSelectedBlockId(this.browser);
chai.assert.notEqual(
focusedBlockId,
'math_number_shadow_1',
'Newly pasted block should be selected',
);
const focusedBlockType = await getSelectedBlockType(this.browser);
chai.assert.equal(
focusedBlockType,
'math_number',
'Newly pasted block should be selected',
);
const focusedBlockIsShadow = await this.browser.execute(() => {
return Blockly.common.getSelected().isShadow();
});
chai.assert.isFalse(
focusedBlockIsShadow,
'Expected the pasted version of the block to not be a shadow block',
);
});
test('Copy block from flyout, paste to main workspace', async function () {
// Open flyout
await getCategory(this.browser, 'Logic').then((category) =>
category.click(),
);
// Focus on first block in flyout
await this.browser.execute(() => {
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
const block = ws.getBlocksByType('controls_if')[0];
Blockly.getFocusManager().focusNode(block);
});
await this.browser.pause(PAUSE_TIME);
// Copy
await this.browser.keys([Key.Ctrl, 'c']);
await this.browser.pause(PAUSE_TIME);
// Select the main workspace
await clickWorkspace(this.browser);
await this.browser.pause(PAUSE_TIME);
// Paste
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check that the block is now on the workspace and selected
const allBlocks = await getAllBlocks(this.browser);
chai.assert.equal(
allBlocks.length,
1,
'Expected there to be one block on main workspace after paste from flyout',
);
const focusedBlockType = await getSelectedBlockType(this.browser);
chai.assert.equal(
focusedBlockType,
'controls_if',
'Newly pasted block should be selected',
);
});
test('Copy block from flyout, paste while flyout focused', async function () {
// Open flyout
await getCategory(this.browser, 'Logic').then((category) =>
category.click(),
);
// Focus on first block in flyout
await this.browser.execute(() => {
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
const block = ws.getBlocksByType('controls_if')[0];
Blockly.getFocusManager().focusNode(block);
});
await this.browser.pause(PAUSE_TIME);
// Copy
await this.browser.keys([Key.Ctrl, 'c']);
await this.browser.pause(PAUSE_TIME);
// Paste
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check that the flyout is closed
const flyoutIsVisible = await this.browser
.$('.blocklyToolboxFlyout')
.then((elem) => elem.isDisplayed());
chai.assert.isFalse(flyoutIsVisible, 'Expected flyout to not be open');
// Check that the block is now on the main workspace and selected
const allBlocks = await getAllBlocks(this.browser);
chai.assert.equal(
allBlocks.length,
1,
'Expected there to be one block on main workspace after paste from flyout',
);
const focusedBlockType = await getSelectedBlockType(this.browser);
chai.assert.equal(
focusedBlockType,
'controls_if',
'Newly pasted block should be selected',
);
});
test('Copy block from mutator flyout, paste to mutator workspace', async function () {
// Load the start blocks
await loadStartBlocks(this.browser);
// Open the controls_if mutator
const block = await getBlockTypeFromWorkspace(
this.browser,
'controls_if',
0,
);
await openMutatorForBlock(this.browser, block);
// Select the first block in the mutator flyout
await this.browser.execute(
(blockId, mutatorBlockType) => {
const flyoutBlock = Blockly.getMainWorkspace()
.getBlockById(blockId)
.mutator.getWorkspace()
.getFlyout()
.getWorkspace()
.getBlocksByType(mutatorBlockType)[0];
Blockly.getFocusManager().focusNode(flyoutBlock);
},
'controls_if_1',
'controls_if_elseif',
);
await this.browser.pause(PAUSE_TIME);
// Copy
await this.browser.keys([Key.Ctrl, 'c']);
await this.browser.pause(PAUSE_TIME);
// Paste
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check that the block is now in the mutator workspace and selected
const numberOfIfElseBlocks = await this.browser.execute(
(blockId, mutatorBlockType) => {
return Blockly.getMainWorkspace()
.getBlockById(blockId)
.mutator.getWorkspace()
.getBlocksByType(mutatorBlockType).length;
},
'controls_if_1',
'controls_if_elseif',
);
chai.assert.equal(
numberOfIfElseBlocks,
1,
'Expected there to be one if_else block in mutator workspace',
);
const focusedBlockType = await getSelectedBlockType(this.browser);
chai.assert.equal(
focusedBlockType,
'controls_if_elseif',
'Newly pasted block should be selected',
);
});
test('Copy block from mutator flyout, paste to main workspace while mutator open', async function () {
// Load the start blocks
await loadStartBlocks(this.browser);
// Open the controls_if mutator
const block = await getBlockTypeFromWorkspace(
this.browser,
'controls_if',
0,
);
await openMutatorForBlock(this.browser, block);
// Select the first block in the mutator flyout
await this.browser.execute(
(blockId, mutatorBlockType) => {
const flyoutBlock = Blockly.getMainWorkspace()
.getBlockById(blockId)
.mutator.getWorkspace()
.getFlyout()
.getWorkspace()
.getBlocksByType(mutatorBlockType)[0];
Blockly.getFocusManager().focusNode(flyoutBlock);
},
'controls_if_1',
'controls_if_elseif',
);
await this.browser.pause(PAUSE_TIME);
// Copy
await this.browser.keys([Key.Ctrl, 'c']);
await this.browser.pause(PAUSE_TIME);
// Click the main workspace
await clickWorkspace(this.browser);
// Paste
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check that the block is now in the mutator workspace and selected
const numberOfIfElseBlocks = await this.browser.execute(
(blockId, mutatorBlockType) => {
return Blockly.getMainWorkspace()
.getBlockById(blockId)
.mutator.getWorkspace()
.getBlocksByType(mutatorBlockType).length;
},
'controls_if_1',
'controls_if_elseif',
);
chai.assert.equal(
numberOfIfElseBlocks,
1,
'Expected there to be one if_else block in mutator workspace',
);
const focusedBlockType = await getSelectedBlockType(this.browser);
chai.assert.equal(
focusedBlockType,
'controls_if_elseif',
'Newly pasted block should be selected',
);
// Check that there are no new blocks on the main workspace
const numberOfIfElseBlocksOnMainWorkspace = await this.browser.execute(
(mutatorBlockType) => {
return Blockly.getMainWorkspace().getBlocksByType(mutatorBlockType)
.length;
},
'controls_if_elseif',
);
chai.assert.equal(
numberOfIfElseBlocksOnMainWorkspace,
0,
'Mutator blocks should not appear on main workspace',
);
});
test('Copy block from mutator flyout, paste to main workspace while mutator closed', async function () {
// Load the start blocks
await loadStartBlocks(this.browser);
// Open the controls_if mutator
const block = await getBlockTypeFromWorkspace(
this.browser,
'controls_if',
0,
);
await openMutatorForBlock(this.browser, block);
// Select the first block in the mutator flyout
await this.browser.execute(
(blockId, mutatorBlockType) => {
const flyoutBlock = Blockly.getMainWorkspace()
.getBlockById(blockId)
.mutator.getWorkspace()
.getFlyout()
.getWorkspace()
.getBlocksByType(mutatorBlockType)[0];
Blockly.getFocusManager().focusNode(flyoutBlock);
},
'controls_if_1',
'controls_if_elseif',
);
await this.browser.pause(PAUSE_TIME);
// Copy
await this.browser.keys([Key.Ctrl, 'c']);
await this.browser.pause(PAUSE_TIME);
// Close the mutator flyout (calling this method on open mutator closes it)
await openMutatorForBlock(this.browser, block);
// Click the main workspace
await clickWorkspace(this.browser);
// Paste
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check that there are no new blocks on the main workspace
const numberOfIfElseBlocksOnMainWorkspace = await this.browser.execute(
(mutatorBlockType) => {
return Blockly.getMainWorkspace().getBlocksByType(mutatorBlockType)
.length;
},
'controls_if_elseif',
);
chai.assert.equal(
numberOfIfElseBlocksOnMainWorkspace,
0,
'Mutator blocks should not appear on main workspace',
);
});
test('Copy workspace comment, paste to main workspace', async function () {
// Add a workspace comment to the workspace
await this.browser.execute(() => {
const workspace = Blockly.getMainWorkspace();
const json = {
'workspaceComments': [
{
'height': 100,
'width': 120,
'id': 'workspace_comment_1',
'x': 13,
'y': -12,
'text': 'This is a comment',
},
],
};
Blockly.serialization.workspaces.load(json, workspace);
});
await this.browser.pause(PAUSE_TIME);
// Select the workspace comment
await this.browser.execute(() => {
const comment = Blockly.getMainWorkspace().getCommentById(
'workspace_comment_1',
);
Blockly.getFocusManager().focusNode(comment);
});
await this.browser.pause(PAUSE_TIME);
// Copy
await this.browser.keys([Key.Ctrl, 'c']);
await this.browser.pause(PAUSE_TIME);
// Click the main workspace
await clickWorkspace(this.browser);
// Paste
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check that there are 2 comments on the workspace
const numberOfComments = await this.browser.execute(() => {
return Blockly.getMainWorkspace().getTopComments().length;
});
chai.assert.equal(
numberOfComments,
2,
'Expected 2 workspace comments after pasting',
);
});
test('Cut block from main workspace, paste to main workspace', async function () {
await loadStartBlocks(this.browser);
// Select and cut the "true" block
await focusOnBlock(this.browser, 'logic_boolean_1');
await this.browser.pause(PAUSE_TIME);
await this.browser.keys([Key.Ctrl, 'x']);
await this.browser.pause(PAUSE_TIME);
// Check that the "true" block was deleted
const trueBlock = await this.browser.execute(() => {
return Blockly.getMainWorkspace().getBlockById('logic_boolean_1') ?? null;
});
chai.assert.isNull(trueBlock);
// Check how many blocks there are before pasting
const allBlocksBeforePaste = await getAllBlocks(this.browser);
// Paste the block while still in the main workspace
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check result
const allBlocksAfterPaste = await getAllBlocks(this.browser);
chai.assert.equal(
allBlocksAfterPaste.length,
allBlocksBeforePaste.length + 1,
'Expected there to be one additional block after paste',
);
});
test('Cannot cut block from flyout', async function () {
// Open flyout
await getCategory(this.browser, 'Logic').then((category) =>
category.click(),
);
// Focus on first block in flyout
await this.browser.execute(() => {
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
const block = ws.getBlocksByType('controls_if')[0];
Blockly.getFocusManager().focusNode(block);
});
await this.browser.pause(PAUSE_TIME);
// Cut
await this.browser.keys([Key.Ctrl, 'x']);
await this.browser.pause(PAUSE_TIME);
// Select the main workspace
await clickWorkspace(this.browser);
await this.browser.pause(PAUSE_TIME);
// Paste
await this.browser.keys([Key.Ctrl, 'v']);
await this.browser.pause(PAUSE_TIME);
// Check that no block was pasted
const allBlocks = await getAllBlocks(this.browser);
chai.assert.equal(
allBlocks.length,
0,
'Expected no blocks in the workspace because nothing to paste',
);
});
});

View File

@@ -127,6 +127,23 @@ export const screenDirection = {
LTR: 1,
};
/**
* Focuses and selects a block with the provided ID.
*
* This throws an error if no block exists for the specified ID.
*
* @param browser The active WebdriverIO Browser object.
* @param blockId The ID of the block to select.
*/
export async function focusOnBlock(browser, blockId) {
return await browser.execute((blockId) => {
const workspaceSvg = Blockly.getMainWorkspace();
const block = workspaceSvg.getBlockById(blockId);
if (!block) throw new Error(`No block found with ID: ${blockId}.`);
Blockly.getFocusManager().focusNode(block);
}, blockId);
}
/**
* @param browser The active WebdriverIO Browser object.
* @return A Promise that resolves to the ID of the currently selected block.
@@ -138,6 +155,17 @@ export async function getSelectedBlockId(browser) {
});
}
/**
* @param browser The active WebdriverIO Browser object.
* @return A Promise that resolves to the ID of the currently selected block.
*/
export async function getSelectedBlockType(browser) {
return await browser.execute(() => {
// Note: selected is an ICopyable and I am assuming that it is a BlockSvg.
return Blockly.common.getSelected()?.type;
});
}
/**
* @param browser The active WebdriverIO Browser object.
* @return A Promise that resolves to the selected block's root SVG element,

View File

@@ -11,7 +11,7 @@
import * as chai from 'chai';
import {Key} from 'webdriverio';
import {
dragBlockTypeFromFlyout,
getBlockTypeFromCategory,
getCategory,
PAUSE_TIME,
screenDirection,
@@ -148,7 +148,12 @@ async function openCategories(browser, categoryList, directionMultiplier) {
continue;
}
const blockType = await getNthBlockType(browser, categoryName, i);
dragBlockTypeFromFlyout(browser, categoryName, blockType, 50, 20);
const blockElem = await getBlockTypeFromCategory(
browser,
categoryName,
blockType,
);
await blockElem.dragAndDrop({x: 50 * directionMultiplier, y: 20});
await browser.pause(PAUSE_TIME);
// Should be one top level block on the workspace.
const topBlockCount = await browser.execute(() => {
@@ -174,9 +179,9 @@ async function openCategories(browser, categoryList, directionMultiplier) {
chai.assert.equal(failureCount, 0);
}
// TODO (#9217) These take too long to run and are very flakey. Need to find a
// better way to test whatever this is trying to test.
suite.skip('Open toolbox categories', function () {
// TODO (#9217) These take too long to run and are very flakey. Need to pull
// these out into their own test runner.
suite('Open toolbox categories', function () {
this.timeout(0);
test('opening every toolbox category in the category toolbox in LTR', async function () {

View File

@@ -5,7 +5,7 @@
*/
import {Align} from '../../build/src/core/inputs/align.js';
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -11,7 +11,7 @@ import {IconType} from '../../build/src/core/icons/icon_types.js';
import {EndRowInput} from '../../build/src/core/inputs/end_row_input.js';
import {isCommentIcon} from '../../build/src/core/interfaces/i_comment_icon.js';
import {Size} from '../../build/src/core/utils/size.js';
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {createRenderedBlock} from './test_helpers/block_definitions.js';
import {
createChangeListenerSpy,
@@ -201,6 +201,35 @@ suite('Blocks', function () {
assertUnpluggedHealFailed(blocks);
});
test('Disconnect top of stack with immovable sibling', function () {
this.blocks.B.setMovable(false);
this.blocks.A.unplug(true);
assert.equal(this.blocks.A.nextConnection.targetBlock(), this.blocks.B);
assert.isNull(this.blocks.B.nextConnection.targetBlock());
assert.isNull(this.blocks.C.previousConnection.targetBlock());
});
test('Heal with immovable sibling mid-stack', function () {
const blockD = this.workspace.newBlock('stack_block', 'd');
this.blocks.C.nextConnection.connect(blockD.previousConnection);
this.blocks.C.setMovable(false);
this.blocks.B.unplug(true);
assert.equal(this.blocks.A.nextConnection.targetBlock(), blockD);
assert.equal(this.blocks.B.nextConnection.targetBlock(), this.blocks.C);
assert.isNull(this.blocks.C.nextConnection.targetBlock());
});
test('Heal with immovable sibling and shadow sibling mid-stack', function () {
const blockD = this.workspace.newBlock('stack_block', 'd');
const blockE = this.workspace.newBlock('stack_block', 'e');
this.blocks.C.nextConnection.connect(blockD.previousConnection);
blockD.nextConnection.connect(blockE.previousConnection);
this.blocks.C.setMovable(false);
blockD.setShadow(true);
this.blocks.B.unplug(true);
assert.equal(this.blocks.A.nextConnection.targetBlock(), blockE);
assert.equal(this.blocks.B.nextConnection.targetBlock(), this.blocks.C);
assert.equal(this.blocks.C.nextConnection.targetBlock(), blockD);
assert.isNull(blockD.nextConnection.targetBlock());
});
test('Child is shadow', function () {
const blocks = this.blocks;
blocks.C.setShadow(true);

View File

@@ -5,7 +5,7 @@
*/
import {ConnectionType} from '../../../build/src/core/connection_type.js';
import {assert} from '../../../node_modules/chai/chai.js';
import {assert} from '../../../node_modules/chai/index.js';
import {defineStatementBlock} from '../test_helpers/block_definitions.js';
import {runSerializationTestSuite} from '../test_helpers/serialization.js';
import {

View File

@@ -5,7 +5,7 @@
*/
import * as eventUtils from '../../../build/src/core/events/utils.js';
import {assert} from '../../../node_modules/chai/chai.js';
import {assert} from '../../../node_modules/chai/index.js';
import {runSerializationTestSuite} from '../test_helpers/serialization.js';
import {
sharedTestSetup,

View File

@@ -5,7 +5,7 @@
*/
import * as Blockly from '../../../build/src/core/blockly.js';
import {assert} from '../../../node_modules/chai/chai.js';
import {assert} from '../../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -5,7 +5,7 @@
*/
import * as Blockly from '../../../build/src/core/blockly.js';
import {assert} from '../../../node_modules/chai/chai.js';
import {assert} from '../../../node_modules/chai/index.js';
import {defineRowBlock} from '../test_helpers/block_definitions.js';
import {
assertCallBlockStructure,

View File

@@ -5,7 +5,7 @@
*/
import {nameUsedWithConflictingParam} from '../../../build/src/core/variables.js';
import {assert} from '../../../node_modules/chai/chai.js';
import {assert} from '../../../node_modules/chai/index.js';
import {
MockParameterModelWithVar,
MockProcedureModel,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
assertEventFired,
createChangeListenerSpy,
@@ -61,6 +61,31 @@ suite('Clipboard', function () {
);
});
test('pasting blocks includes next blocks if requested', function () {
const block = Blockly.serialization.blocks.append(
{
'type': 'controls_if',
'id': 'blockId',
'next': {
'block': {
'type': 'controls_if',
'id': 'blockId2',
},
},
},
this.workspace,
);
assert.equal(this.workspace.getBlocksByType('controls_if').length, 2);
// Both blocks should be copied
const data = block.toCopyData(true);
this.clock.runAll();
Blockly.clipboard.paste(data, this.workspace);
this.clock.runAll();
// After pasting, we should have gone from 2 to 4 blocks.
assert.equal(this.workspace.getBlocksByType('controls_if').length, 4);
});
test('copied from a mutator pastes them into the mutator', async function () {
const block = Blockly.serialization.blocks.append(
{
@@ -132,6 +157,34 @@ suite('Clipboard', function () {
);
});
test('pasted blocks are bumped to not overlap in RTL', function () {
this.workspace.dispose();
this.workspace = Blockly.inject('blocklyDiv', {rtl: true});
const block = Blockly.serialization.blocks.append(
{
'type': 'controls_if',
'x': 38,
'y': 13,
},
this.workspace,
);
const data = block.toCopyData();
const newBlock = Blockly.clipboard.paste(data, this.workspace);
const oldBlockXY = block.getRelativeToSurfaceXY();
assert.deepEqual(
newBlock.getRelativeToSurfaceXY(),
new Blockly.utils.Coordinate(
oldBlockXY.x - Blockly.config.snapRadius,
oldBlockXY.y + Blockly.config.snapRadius * 2,
),
);
// Restore an LTR workspace.
this.workspace.dispose();
this.workspace = Blockly.inject('blocklyDiv');
});
test('pasted blocks are bumped to be outside the connection snap radius', function () {
Blockly.serialization.workspaces.load(
{
@@ -183,5 +236,28 @@ suite('Clipboard', function () {
new Blockly.utils.Coordinate(40, 40),
);
});
test('pasted comments are bumped to not overlap in RTL', function () {
this.workspace.dispose();
this.workspace = Blockly.inject('blocklyDiv', {rtl: true});
Blockly.Xml.domToWorkspace(
Blockly.utils.xml.textToDom(
'<xml><comment id="test" x=10 y=10/></xml>',
),
this.workspace,
);
const comment = this.workspace.getTopComments(false)[0];
const data = comment.toCopyData();
const newComment = Blockly.clipboard.paste(data, this.workspace);
const oldCommentXY = comment.getRelativeToSurfaceXY();
assert.deepEqual(
newComment.getRelativeToSurfaceXY(),
new Blockly.utils.Coordinate(oldCommentXY.x - 30, oldCommentXY.y + 30),
);
// Restore an LTR workspace.
this.workspace.dispose();
this.workspace = Blockly.inject('blocklyDiv');
});
});
});

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -5,7 +5,7 @@
*/
import {EventType} from '../../build/src/core/events/type.js';
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {assertEventFired} from './test_helpers/events.js';
import {
sharedTestSetup,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -5,7 +5,7 @@
*/
import {ConnectionType} from '../../build/src/core/connection_type.js';
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -6,7 +6,7 @@
import {ConnectionType} from '../../build/src/core/connection_type.js';
import * as idGenerator from '../../build/src/core/utils/idgenerator.js';
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
defineRowBlock,
defineStackBlock,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -6,7 +6,7 @@
import {callbackFactory} from '../../build/src/core/contextmenu.js';
import * as xmlUtils from '../../build/src/core/utils/xml.js';
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {createRenderedBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -6,7 +6,7 @@
import {Rect} from '../../build/src/core/utils/rect.js';
import * as style from '../../build/src/core/utils/style.js';
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineMutatorBlocks} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,

View File

@@ -5,7 +5,7 @@
*/
import {EventType} from '../../build/src/core/events/type.js';
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineRowBlock} from './test_helpers/block_definitions.js';
import {assertEventFired} from './test_helpers/events.js';
import {

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineRowBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineRowBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineRowBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineMutatorBlocks} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineRowBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineRowBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,

View File

@@ -6,7 +6,7 @@
import * as Blockly from '../../build/src/core/blockly.js';
import * as eventUtils from '../../build/src/core/events/utils.js';
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
assertEventEquals,
assertNthCallEventArgEquals,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,

Some files were not shown because too many files have changed in this diff Show More