release: v12.2.0

release: v12.2.0
Merge pull request #9224 from google/rc/v12.2.0
This commit is contained in:
Maribeth Moffatt
2025-07-09 13:46:31 -07:00
committed by GitHub
86 changed files with 3122 additions and 2312 deletions

View File

@@ -0,0 +1,66 @@
# Workflow for running the keyboard navigation plugin's automated tests.
name: Keyboard Navigation Automated Tests
on:
workflow_dispatch:
pull_request:
push:
branches:
- develop
permissions:
contents: read
jobs:
webdriverio_tests:
name: WebdriverIO tests
timeout-minutes: 10
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- name: Checkout core Blockly
uses: actions/checkout@v4
with:
path: core-blockly
- name: Checkout keyboard navigation plugin
uses: actions/checkout@v4
with:
repository: 'google/blockly-keyboard-experimentation'
ref: 'main'
path: blockly-keyboard-experimentation
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: NPM install
run: |
cd core-blockly
npm install
cd ..
cd blockly-keyboard-experimentation
npm install
cd ..
- name: Link latest core develop with plugin
run: |
cd core-blockly
npm run package
cd dist
npm link
cd ../../blockly-keyboard-experimentation
npm link blockly
cd ..
- name: Run keyboard navigation plugin tests
run: |
cd blockly-keyboard-experimentation
npm run test

View File

@@ -791,6 +791,7 @@ export class Block {
isDeletable(): boolean {
return (
this.deletable &&
!this.isInFlyout &&
!this.shadow &&
!this.isDeadOrDying() &&
!this.workspace.isReadOnly()
@@ -824,6 +825,7 @@ export class Block {
isMovable(): boolean {
return (
this.movable &&
!this.isInFlyout &&
!this.shadow &&
!this.isDeadOrDying() &&
!this.workspace.isReadOnly()

View File

@@ -299,8 +299,19 @@ export class BlockSvg
}
const oldXY = this.getRelativeToSurfaceXY();
const focusedNode = getFocusManager().getFocusedNode();
const restoreFocus = this.getSvgRoot().contains(
focusedNode?.getFocusableElement() ?? null,
);
if (newParent) {
(newParent as BlockSvg).getSvgRoot().appendChild(svgRoot);
// appendChild() clears focus state, so re-focus the previously focused
// node in case it was this block and would otherwise lose its focus. Once
// Element.moveBefore() has better browser support, it should be used
// instead.
if (restoreFocus && focusedNode) {
getFocusManager().focusNode(focusedNode);
}
} else if (oldParent) {
// If we are losing a parent, we want to move our DOM element to the
// root of the workspace. Try to insert it before any top-level
@@ -319,6 +330,13 @@ export class BlockSvg
canvas.insertBefore(svgRoot, draggingBlockElement);
} else {
canvas.appendChild(svgRoot);
// appendChild() clears focus state, so re-focus the previously focused
// node in case it was this block and would otherwise lose its focus. Once
// Element.moveBefore() has better browser support, it should be used
// instead.
if (restoreFocus && focusedNode) {
getFocusManager().focusNode(focusedNode);
}
}
this.translate(oldXY.x, oldXY.y);
}
@@ -849,10 +867,30 @@ export class BlockSvg
Tooltip.dispose();
ContextMenu.hide();
// If this block was focused, focus its parent or workspace instead.
// If this block (or a descendant) was focused, focus its parent or
// workspace instead.
const focusManager = getFocusManager();
if (focusManager.getFocusedNode() === this) {
const parent = this.getParent();
if (
this.getSvgRoot().contains(
focusManager.getFocusedNode()?.getFocusableElement() ?? null,
)
) {
let parent: BlockSvg | undefined | null = this.getParent();
if (!parent) {
// In some cases, blocks are disconnected from their parents before
// being deleted. Attempt to infer if there was a parent by checking
// for a connection within a radius of 0. Even if this wasn't a parent,
// it must be adjacent to this block and so is as good an option as any
// to focus after deleting.
const connection = this.outputConnection ?? this.previousConnection;
if (connection) {
const targetConnection = connection.closest(
0,
new Coordinate(0, 0),
).connection;
parent = targetConnection?.getSourceBlock();
}
}
if (parent) {
focusManager.focusNode(parent);
} else {
@@ -1721,6 +1759,11 @@ export class BlockSvg
this.dragStrategy = dragStrategy;
}
/** Returns whether this block is copyable or not. */
isCopyable(): boolean {
return this.isOwnDeletable() && this.isOwnMovable();
}
/** Returns whether this block is movable or not. */
override isMovable(): boolean {
return this.dragStrategy.isMovable();

View File

@@ -153,7 +153,11 @@ export class MiniWorkspaceBubble extends Bubble {
* are dealt with by resizing the workspace to show them.
*/
private bumpBlocksIntoBounds() {
if (this.miniWorkspace.isDragging()) return;
if (
this.miniWorkspace.isDragging() &&
!this.miniWorkspace.keyboardMoveInProgress
)
return;
const MARGIN = 20;
@@ -185,7 +189,15 @@ export class MiniWorkspaceBubble extends Bubble {
* mini workspace.
*/
private updateBubbleSize() {
if (this.miniWorkspace.isDragging()) return;
if (
this.miniWorkspace.isDragging() &&
!this.miniWorkspace.keyboardMoveInProgress
)
return;
// Disable autolayout if a keyboard move is in progress to prevent the
// mutator bubble from jumping around.
this.autoLayout &&= !this.miniWorkspace.keyboardMoveInProgress;
const currSize = this.getSize();
const newSize = this.calculateWorkspaceSize();

View File

@@ -173,6 +173,11 @@ export class TextInputBubble extends Bubble {
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);
}

View File

@@ -9,6 +9,7 @@
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
import * as registry from './clipboard/registry.js';
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
import {isSelectable} from './interfaces/i_selectable.js';
import * as globalRegistry from './registry.js';
import {Coordinate} from './utils/coordinate.js';
import {WorkspaceSvg} from './workspace_svg.js';
@@ -18,18 +19,119 @@ let stashedCopyData: ICopyData | null = null;
let stashedWorkspace: WorkspaceSvg | null = null;
let stashedCoordinates: Coordinate | undefined = undefined;
/**
* Private version of copy for stubbing in tests.
* Copy a copyable item, and record its data and the workspace it was
* copied from.
*
* This function does not perform any checks to ensure the copy
* should be allowed, e.g. to ensure the block is deletable. Such
* checks should be done before calling this function.
*
* Note that if the copyable item is not an `ISelectable` or its
* `workspace` property is not a `WorkspaceSvg`, the copy will be
* successful, but there will be no saved workspace data. This will
* impact the ability to paste the data unless you explictily pass
* a workspace into the paste method.
*
* @param toCopy item to copy.
* @param location location to save as a potential paste location.
* @returns the copied data if copy was successful, otherwise null.
*/
function copyInternal<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
export function copy<T extends ICopyData>(
toCopy: ICopyable<T>,
location?: Coordinate,
): T | null {
const data = toCopy.toCopyData();
stashedCopyData = data;
stashedWorkspace = (toCopy as any).workspace ?? null;
if (isSelectable(toCopy) && toCopy.workspace instanceof WorkspaceSvg) {
stashedWorkspace = toCopy.workspace;
} else {
stashedWorkspace = null;
}
stashedCoordinates = location;
return data;
}
/**
* Paste a pasteable element into the workspace.
* Gets the copy data for the last item copied. This is useful if you
* are implementing custom copy/paste behavior. If you want the default
* behavior, just use the copy and paste methods directly.
*
* @returns copy data for the last item copied, or null if none set.
*/
export function getLastCopiedData() {
return stashedCopyData;
}
/**
* Sets the last copied item. You should call this method if you implement
* custom copy behavior, so that other callers are working with the correct
* data. This method is called automatically if you use the built-in copy
* method.
*
* @param copyData copy data for the last item copied.
*/
export function setLastCopiedData(copyData: ICopyData) {
stashedCopyData = copyData;
}
/**
* Gets the workspace that was last copied from. This is useful if you
* are implementing custom copy/paste behavior and want to paste on the
* same workspace that was copied from. If you want the default behavior,
* just use the copy and paste methods directly.
*
* @returns workspace that was last copied from, or null if none set.
*/
export function getLastCopiedWorkspace() {
return stashedWorkspace;
}
/**
* Sets the workspace that was last copied from. You should call this method
* if you implement custom copy behavior, so that other callers are working
* with the correct data. This method is called automatically if you use the
* built-in copy method.
*
* @param workspace workspace that was last copied from.
*/
export function setLastCopiedWorkspace(workspace: WorkspaceSvg) {
stashedWorkspace = workspace;
}
/**
* Gets the location that was last copied from. This is useful if you
* are implementing custom copy/paste behavior. If you want the
* default behavior, just use the copy and paste methods directly.
*
* @returns last saved location, or null if none set.
*/
export function getLastCopiedLocation() {
return stashedCoordinates;
}
/**
* Sets the location that was last copied from. You should call this method
* if you implement custom copy behavior, so that other callers are working
* with the correct data. This method is called automatically if you use the
* built-in copy method.
*
* @param location last saved location, which can be used to paste at.
*/
export function setLastCopiedLocation(location: Coordinate) {
stashedCoordinates = location;
}
/**
* Paste a pasteable element into the given workspace.
*
* This function does not perform any checks to ensure the paste
* is allowed, e.g. that the workspace is rendered or the block
* is pasteable. Such checks should be done before calling this
* function.
*
* @param copyData The data to paste into the workspace.
* @param workspace The workspace to paste the data into.
@@ -43,7 +145,7 @@ export function paste<T extends ICopyData>(
): ICopyable<T> | null;
/**
* Pastes the last copied ICopyable into the workspace.
* Pastes the last copied ICopyable into the last copied-from workspace.
*
* @returns the pasted thing if the paste was successful, null otherwise.
*/
@@ -65,7 +167,7 @@ export function paste<T extends ICopyData>(
): ICopyable<ICopyData> | null {
if (!copyData || !workspace) {
if (!stashedCopyData || !stashedWorkspace) return null;
return pasteFromData(stashedCopyData, stashedWorkspace);
return pasteFromData(stashedCopyData, stashedWorkspace, stashedCoordinates);
}
return pasteFromData(copyData, workspace, coordinate);
}
@@ -85,31 +187,11 @@ function pasteFromData<T extends ICopyData>(
): ICopyable<T> | null {
workspace = workspace.isMutator
? workspace
: (workspace.getRootWorkspace() ?? workspace);
: // Use the parent workspace if it exists (e.g. for pasting into flyouts)
(workspace.options.parentWorkspace ?? workspace);
return (globalRegistry
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;
}
/**
* Private version of duplicate for stubbing in tests.
*/
function duplicateInternal<
U extends ICopyData,
T extends ICopyable<U> & IHasWorkspace,
>(toDuplicate: T): T | null {
const data = toDuplicate.toCopyData();
if (!data) return null;
return paste(data, toDuplicate.workspace) as T;
}
interface IHasWorkspace {
workspace: WorkspaceSvg;
}
export const TEST_ONLY = {
duplicateInternal,
copyInternal,
};
export {BlockCopyData, BlockPaster, registry};

View File

@@ -4,6 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
export {CollapseCommentBarButton} from './comments/collapse_comment_bar_button.js';
export {CommentBarButton} from './comments/comment_bar_button.js';
export {CommentEditor} from './comments/comment_editor.js';
export {CommentView} from './comments/comment_view.js';
export {DeleteCommentBarButton} from './comments/delete_comment_bar_button.js';
export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
export {WorkspaceComment} from './comments/workspace_comment.js';

View File

@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as browserEvents from '../browser_events.js';
import * as touch from '../touch.js';
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';
/**
* Magic string appended to the comment ID to create a unique ID for this button.
*/
export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER =
'_collapse_bar_button';
/**
* Button that toggles the collapsed state of a comment.
*/
export class CollapseCommentBarButton extends CommentBarButton {
/**
* Opaque ID used to unbind event handlers during disposal.
*/
private readonly bindId: browserEvents.Data;
/**
* SVG image displayed on this button.
*/
protected override readonly icon: SVGImageElement;
/**
* Creates a new CollapseCommentBarButton instance.
*
* @param id The ID of this button's parent comment.
* @param workspace The workspace this button's parent comment is displayed on.
* @param container An SVG group that this button should be a child of.
*/
constructor(
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
) {
super(id, workspace, container);
this.icon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyFoldoutIcon',
'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`,
'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
},
this.container,
);
this.bindId = browserEvents.conditionalBind(
this.icon,
'pointerdown',
this,
this.performAction.bind(this),
);
}
/**
* Disposes of this button.
*/
dispose() {
browserEvents.unbind(this.bindId);
}
/**
* Adjusts the positioning of this button within its container.
*/
override reposition() {
const margin = this.getMargin();
this.icon.setAttribute('y', `${margin}`);
this.icon.setAttribute('x', `${margin}`);
}
/**
* Toggles the collapsed state of the parent comment.
*
* @param e The event that triggered this action.
*/
override performAction(e?: Event) {
touch.clearTouchIdentifier();
const comment = this.getParentComment();
comment.view.bringToFront();
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
comment.setCollapsed(!comment.isCollapsed());
this.workspace.hideChaff();
e?.stopPropagation();
}
}

View File

@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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';
/**
* Button displayed on a comment's top bar.
*/
export abstract class CommentBarButton implements IFocusableNode {
/**
* SVG image displayed on this button.
*/
protected abstract readonly icon: SVGImageElement;
/**
* Creates a new CommentBarButton instance.
*
* @param id The ID of this button's parent comment.
* @param workspace The workspace this button's parent comment is on.
* @param container An SVG group that this button should be a child of.
*/
constructor(
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
) {}
/**
* Returns whether or not this button is currently visible.
*/
isVisible(): boolean {
return this.icon.checkVisibility();
}
/**
* Returns the parent comment 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;
}
/** Adjusts the position of this button within its parent container. */
abstract reposition(): void;
/** Perform the action this button should take when it is acted on. */
abstract performAction(e?: Event): void;
/**
* Returns the dimensions of this button in workspace coordinates.
*
* @param includeMargin True to include the margin when calculating the size.
* @returns The size of this button.
*/
getSize(includeMargin = false): Rect {
const bounds = this.icon.getBBox();
const rect = Rect.from(bounds);
if (includeMargin) {
const margin = this.getMargin();
rect.left -= margin;
rect.top -= margin;
rect.bottom += margin;
rect.right += margin;
}
return rect;
}
/** Returns the margin in workspace coordinates surrounding this button. */
getMargin(): number {
return (this.container.getBBox().height - this.icon.getBBox().height) / 2;
}
/** Returns a DOM element representing this button that can receive focus. */
getFocusableElement() {
return this.icon;
}
/** Returns the workspace this button is a child of. */
getFocusableTree() {
return this.workspace;
}
/** Called when this button's focusable DOM element gains focus. */
onNodeFocus() {}
/** Called when this button's focusable DOM element loses focus. */
onNodeBlur() {}
/** Returns whether this button can be focused. True if it is visible. */
canBeFocused() {
return this.isVisible();
}
}

View File

@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as browserEvents from '../browser_events.js';
import {getFocusManager} from '../focus_manager.js';
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 {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/**
* String added to the ID of a workspace comment to identify
* the focusable node for the comment editor.
*/
export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_';
/** The part of a comment that can be typed into. */
export class CommentEditor implements IFocusableNode {
id?: string;
/** The foreignObject containing the HTML text area. */
private foreignObject: SVGForeignObjectElement;
/** The text area where the user can type. */
private textArea: HTMLTextAreaElement;
/** Listeners for changes to text. */
private textChangeListeners: Array<
(oldText: string, newText: string) => void
> = [];
/** The current text of the comment. Updates on text area change. */
private text: string = '';
constructor(
public workspace: WorkspaceSvg,
commentId?: string,
private onFinishEditing?: () => void,
) {
this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, {
'class': 'blocklyCommentForeignObject',
});
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
this.textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
this.textArea.setAttribute('tabindex', '-1');
dom.addClass(this.textArea, 'blocklyCommentText');
dom.addClass(this.textArea, 'blocklyTextarea');
dom.addClass(this.textArea, 'blocklyText');
body.appendChild(this.textArea);
this.foreignObject.appendChild(body);
if (commentId) {
this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER;
this.textArea.setAttribute('id', this.id);
}
// Register browser event listeners for the user typing in the textarea.
browserEvents.conditionalBind(
this.textArea,
'change',
this,
this.onTextChange,
);
// Register listener for pointerdown to focus the textarea.
browserEvents.conditionalBind(
this.textArea,
'pointerdown',
this,
(e: PointerEvent) => {
// don't allow this event to bubble up
// and steal focus away from the editor/comment.
e.stopPropagation();
getFocusManager().focusNode(this);
touch.clearTouchIdentifier();
},
);
// Register listener for keydown events that would finish editing.
browserEvents.conditionalBind(
this.textArea,
'keydown',
this,
this.handleKeyDown,
);
}
/** Gets the dom structure for this comment editor. */
getDom(): SVGForeignObjectElement {
return this.foreignObject;
}
/** Gets the current text of the comment. */
getText(): string {
return this.text;
}
/** Sets the current text of the comment and fires change listeners. */
setText(text: string) {
this.textArea.value = text;
this.onTextChange();
}
/**
* Triggers listeners when the text of the comment changes, either
* programmatically or manually by the user.
*/
private onTextChange() {
const oldText = this.text;
this.text = this.textArea.value;
// Loop through listeners backwards in case they remove themselves.
for (let i = this.textChangeListeners.length - 1; i >= 0; i--) {
this.textChangeListeners[i](oldText, this.text);
}
}
/**
* Do something when the user indicates they've finished editing.
*
* @param e Keyboard event.
*/
private handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
if (this.onFinishEditing) this.onFinishEditing();
e.stopPropagation();
}
}
/** Registers a callback that listens for text changes. */
addTextChangeListener(listener: (oldText: string, newText: string) => void) {
this.textChangeListeners.push(listener);
}
/** Removes the given listener from the list of text change listeners. */
removeTextChangeListener(listener: () => void) {
this.textChangeListeners.splice(
this.textChangeListeners.indexOf(listener),
1,
);
}
/** Sets the placeholder text displayed for an empty comment. */
setPlaceholderText(text: string) {
this.textArea.placeholder = text;
}
/** Sets whether the textarea is editable. If not, the textarea will be readonly. */
setEditable(isEditable: boolean) {
if (isEditable) {
this.textArea.removeAttribute('readonly');
} else {
this.textArea.setAttribute('readonly', 'true');
}
}
/** Update the size of the comment editor element. */
updateSize(size: Size, topBarSize: Size) {
this.foreignObject.setAttribute(
'height',
`${size.height - topBarSize.height}`,
);
this.foreignObject.setAttribute('width', `${size.width}`);
this.foreignObject.setAttribute('y', `${topBarSize.height}`);
if (this.workspace.RTL) {
this.foreignObject.setAttribute('x', `${-size.width}`);
}
}
getFocusableElement(): HTMLElement | SVGElement {
return this.textArea;
}
getFocusableTree(): IFocusableTree {
return this.workspace;
}
onNodeFocus(): void {}
onNodeBlur(): void {}
canBeFocused(): boolean {
if (this.id) return true;
return false;
}
}

View File

@@ -6,6 +6,7 @@
import * as browserEvents from '../browser_events.js';
import * as css from '../css.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node';
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
import * as layers from '../layers.js';
import * as touch from '../touch.js';
@@ -15,13 +16,17 @@ import * as drag from '../utils/drag.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {CollapseCommentBarButton} from './collapse_comment_bar_button.js';
import {CommentBarButton} from './comment_bar_button.js';
import {CommentEditor} from './comment_editor.js';
import {DeleteCommentBarButton} from './delete_comment_bar_button.js';
export class CommentView implements IRenderedElement {
/** The root group element of the comment view. */
private svgRoot: SVGGElement;
/**
* The svg rect element that we use to create a hightlight around the comment.
* The SVG rect element that we use to create a highlight around the comment.
*/
private highlightRect: SVGRectElement;
@@ -31,11 +36,11 @@ export class CommentView implements IRenderedElement {
/** The rect background for the top bar. */
private topBarBackground: SVGRectElement;
/** The delete icon that goes in the top bar. */
private deleteIcon: SVGImageElement;
/** The delete button that goes in the top bar. */
private deleteButton: DeleteCommentBarButton;
/** The foldout icon that goes in the top bar. */
private foldoutIcon: SVGImageElement;
/** The foldout button that goes in the top bar. */
private foldoutButton: CollapseCommentBarButton;
/** The text element that goes in the top bar. */
private textPreview: SVGTextElement;
@@ -46,11 +51,8 @@ export class CommentView implements IRenderedElement {
/** The resize handle element. */
private resizeHandle: SVGImageElement;
/** The foreignObject containing the HTML text area. */
private foreignObject: SVGForeignObjectElement;
/** The text area where the user can type. */
private textArea: HTMLTextAreaElement;
/** The part of the comment view that contains the textarea to edit the comment. */
private commentEditor: CommentEditor;
/** The current size of the comment in workspace units. */
private size: Size;
@@ -64,14 +66,6 @@ export class CommentView implements IRenderedElement {
/** The current location of the comment in workspace coordinates. */
private location: Coordinate = new Coordinate(0, 0);
/** The current text of the comment. Updates on text area change. */
private text: string = '';
/** Listeners for changes to text. */
private textChangeListeners: Array<
(oldText: string, newText: string) => void
> = [];
/** Listeners for changes to size. */
private sizeChangeListeners: Array<(oldSize: Size, newSize: Size) => void> =
[];
@@ -106,7 +100,10 @@ export class CommentView implements IRenderedElement {
/** The default size of newly created comments. */
static defaultCommentSize = new Size(120, 100);
constructor(readonly workspace: WorkspaceSvg) {
constructor(
readonly workspace: WorkspaceSvg,
private commentId: string,
) {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
});
@@ -116,14 +113,13 @@ export class CommentView implements IRenderedElement {
({
topBarGroup: this.topBarGroup,
topBarBackground: this.topBarBackground,
deleteIcon: this.deleteIcon,
foldoutIcon: this.foldoutIcon,
deleteButton: this.deleteButton,
foldoutButton: this.foldoutButton,
textPreview: this.textPreview,
textPreviewNode: this.textPreviewNode,
} = this.createTopBar(this.svgRoot, workspace));
} = this.createTopBar(this.svgRoot));
({foreignObject: this.foreignObject, textArea: this.textArea} =
this.createTextArea(this.svgRoot));
this.commentEditor = this.createTextArea();
this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace);
@@ -154,14 +150,11 @@ export class CommentView implements IRenderedElement {
* Creates the top bar and the elements visually within it.
* Registers event listeners.
*/
private createTopBar(
svgRoot: SVGGElement,
workspace: WorkspaceSvg,
): {
private createTopBar(svgRoot: SVGGElement): {
topBarGroup: SVGGElement;
topBarBackground: SVGRectElement;
deleteIcon: SVGImageElement;
foldoutIcon: SVGImageElement;
deleteButton: DeleteCommentBarButton;
foldoutButton: CollapseCommentBarButton;
textPreview: SVGTextElement;
textPreviewNode: Text;
} {
@@ -179,22 +172,14 @@ export class CommentView implements IRenderedElement {
},
topBarGroup,
);
// TODO: Before merging, does this mean to override an individual image,
// folks need to replace the whole media folder?
const deleteIcon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyDeleteIcon',
'href': `${workspace.options.pathToMedia}delete-icon.svg`,
},
const deleteButton = new DeleteCommentBarButton(
this.commentId,
this.workspace,
topBarGroup,
);
const foldoutIcon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyFoldoutIcon',
'href': `${workspace.options.pathToMedia}foldout-icon.svg`,
},
const foldoutButton = new CollapseCommentBarButton(
this.commentId,
this.workspace,
topBarGroup,
);
const textPreview = dom.createSvgElement(
@@ -207,27 +192,11 @@ export class CommentView implements IRenderedElement {
const textPreviewNode = document.createTextNode('');
textPreview.appendChild(textPreviewNode);
// TODO(toychest): Triggering this on pointerdown means that we can't start
// drags on the foldout icon. We need to open up the gesture system
// to fix this.
browserEvents.conditionalBind(
foldoutIcon,
'pointerdown',
this,
this.onFoldoutDown,
);
browserEvents.conditionalBind(
deleteIcon,
'pointerdown',
this,
this.onDeleteDown,
);
return {
topBarGroup,
topBarBackground,
deleteIcon,
foldoutIcon,
deleteButton,
foldoutButton,
textPreview,
textPreviewNode,
};
@@ -236,33 +205,32 @@ export class CommentView implements IRenderedElement {
/**
* Creates the text area where users can type. Registers event listeners.
*/
private createTextArea(svgRoot: SVGGElement): {
foreignObject: SVGForeignObjectElement;
textArea: HTMLTextAreaElement;
} {
const foreignObject = dom.createSvgElement(
Svg.FOREIGNOBJECT,
{
'class': 'blocklyCommentForeignObject',
},
svgRoot,
private createTextArea() {
// When the user is done editing comment, focus the entire comment.
const onFinishEditing = () => this.svgRoot.focus();
const commentEditor = new CommentEditor(
this.workspace,
this.commentId,
onFinishEditing,
);
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
const textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
dom.addClass(textArea, 'blocklyCommentText');
dom.addClass(textArea, 'blocklyTextarea');
dom.addClass(textArea, 'blocklyText');
body.appendChild(textArea);
foreignObject.appendChild(body);
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
this.svgRoot.appendChild(commentEditor.getDom());
return {foreignObject, textArea};
commentEditor.addTextChangeListener((oldText, newText) => {
this.updateTextPreview(newText);
// Update size in case our minimum size increased.
this.setSize(this.size);
});
return commentEditor;
}
/**
*
* @returns The FocusableNode representing the editor portion of this comment.
*/
getEditorFocusableNode(): IFocusableNode {
return this.commentEditor;
}
/** Creates the DOM elements for the comment resize handle. */
@@ -308,15 +276,10 @@ export class CommentView implements IRenderedElement {
*/
setSizeWithoutFiringEvents(size: Size) {
const topBarSize = this.topBarBackground.getBBox();
const deleteSize = this.deleteIcon.getBBox();
const foldoutSize = this.foldoutIcon.getBBox();
const textPreviewSize = this.textPreview.getBBox();
const resizeSize = this.resizeHandle.getBBox();
size = Size.max(
size,
this.calcMinSize(topBarSize, foldoutSize, deleteSize),
);
size = Size.max(size, this.calcMinSize(topBarSize));
this.size = size;
this.svgRoot.setAttribute('height', `${size.height}`);
@@ -324,16 +287,10 @@ export class CommentView implements IRenderedElement {
this.updateHighlightRect(size);
this.updateTopBarSize(size);
this.updateTextAreaSize(size, topBarSize);
this.updateDeleteIconPosition(size, topBarSize, deleteSize);
this.updateFoldoutIconPosition(topBarSize, foldoutSize);
this.updateTextPreviewSize(
size,
topBarSize,
textPreviewSize,
deleteSize,
resizeSize,
);
this.commentEditor.updateSize(size, topBarSize);
this.deleteButton.reposition();
this.foldoutButton.reposition();
this.updateTextPreviewSize(size, topBarSize, textPreviewSize);
this.updateResizeHandlePosition(size, resizeSize);
}
@@ -355,25 +312,18 @@ export class CommentView implements IRenderedElement {
*
* The minimum height is based on the height of the top bar.
*/
private calcMinSize(
topBarSize: Size,
foldoutSize: Size,
deleteSize: Size,
): Size {
this.updateTextPreview(this.textArea.value ?? '');
private calcMinSize(topBarSize: Size): Size {
this.updateTextPreview(this.commentEditor.getText() ?? '');
const textPreviewWidth = dom.getTextWidth(this.textPreview);
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
let width = textPreviewWidth;
if (this.foldoutIcon.checkVisibility()) {
width += foldoutSize.width + foldoutMargin * 2;
if (this.foldoutButton.isVisible()) {
width += this.foldoutButton.getSize(true).getWidth();
} else if (textPreviewWidth) {
width += 4; // Arbitrary margin before text.
}
if (this.deleteIcon.checkVisibility()) {
width += deleteSize.width + deleteMargin * 2;
if (this.deleteButton.isVisible()) {
width += this.deleteButton.getSize(true).getWidth();
} else if (textPreviewWidth) {
width += 4; // Arbitrary margin after text.
}
@@ -384,16 +334,6 @@ export class CommentView implements IRenderedElement {
return new Size(width, height);
}
/** Calculates the margin that should exist around the delete icon. */
private calcDeleteMargin(topBarSize: Size, deleteSize: Size) {
return (topBarSize.height - deleteSize.height) / 2;
}
/** Calculates the margin that should exist around the foldout icon. */
private calcFoldoutMargin(topBarSize: Size, foldoutSize: Size) {
return (topBarSize.height - foldoutSize.height) / 2;
}
/** Updates the size of the highlight rect to reflect the new size. */
private updateHighlightRect(size: Size) {
this.highlightRect.setAttribute('height', `${size.height}`);
@@ -408,44 +348,6 @@ export class CommentView implements IRenderedElement {
this.topBarBackground.setAttribute('width', `${size.width}`);
}
/** Updates the size of the text area elements to reflect the new size. */
private updateTextAreaSize(size: Size, topBarSize: Size) {
this.foreignObject.setAttribute(
'height',
`${size.height - topBarSize.height}`,
);
this.foreignObject.setAttribute('width', `${size.width}`);
this.foreignObject.setAttribute('y', `${topBarSize.height}`);
if (this.workspace.RTL) {
this.foreignObject.setAttribute('x', `${-size.width}`);
}
}
/**
* Updates the position of the delete icon elements to reflect the new size.
*/
private updateDeleteIconPosition(
size: Size,
topBarSize: Size,
deleteSize: Size,
) {
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
this.deleteIcon.setAttribute('y', `${deleteMargin}`);
this.deleteIcon.setAttribute(
'x',
`${size.width - deleteSize.width - deleteMargin}`,
);
}
/**
* Updates the position of the foldout icon elements to reflect the new size.
*/
private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) {
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
this.foldoutIcon.setAttribute('y', `${foldoutMargin}`);
this.foldoutIcon.setAttribute('x', `${foldoutMargin}`);
}
/**
* Updates the size and position of the text preview elements to reflect the new size.
*/
@@ -453,25 +355,14 @@ export class CommentView implements IRenderedElement {
size: Size,
topBarSize: Size,
textPreviewSize: Size,
deleteSize: Size,
foldoutSize: Size,
) {
const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2;
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
const foldoutSize = this.foldoutButton.getSize(true);
const deleteSize = this.deleteButton.getSize(true);
const textPreviewWidth =
size.width -
foldoutSize.width -
foldoutMargin * 2 -
deleteSize.width -
deleteMargin * 2;
this.textPreview.setAttribute(
'x',
`${
foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1)
}`,
);
size.width - foldoutSize.getWidth() - deleteSize.getWidth();
this.textPreview.setAttribute('x', `${foldoutSize.getWidth()}`);
this.textPreview.setAttribute(
'y',
`${textPreviewMargin + textPreviewSize.height / 2}`,
@@ -622,25 +513,6 @@ export class CommentView implements IRenderedElement {
);
}
/**
* Toggles the collapsedness of the block when we receive a pointer down
* event on the foldout icon.
*/
private onFoldoutDown(e: PointerEvent) {
touch.clearTouchIdentifier();
this.bringToFront();
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
this.setCollapsed(!this.collapsed);
this.workspace.hideChaff();
e.stopPropagation();
}
/** Returns true if the comment is currently editable. */
isEditable(): boolean {
return this.editable;
@@ -652,12 +524,11 @@ export class CommentView implements IRenderedElement {
if (this.editable) {
dom.addClass(this.svgRoot, 'blocklyEditable');
dom.removeClass(this.svgRoot, 'blocklyReadonly');
this.textArea.removeAttribute('readonly');
} else {
dom.removeClass(this.svgRoot, 'blocklyEditable');
dom.addClass(this.svgRoot, 'blocklyReadonly');
this.textArea.setAttribute('readonly', 'true');
}
this.commentEditor.setEditable(editable);
}
/** Returns the current location of the comment in workspace coordinates. */
@@ -678,49 +549,29 @@ export class CommentView implements IRenderedElement {
);
}
/** Retursn the current text of the comment. */
/** Returns the current text of the comment. */
getText() {
return this.text;
return this.commentEditor.getText();
}
/** Sets the current text of the comment. */
setText(text: string) {
this.textArea.value = text;
this.onTextChange();
this.commentEditor.setText(text);
}
/** Sets the placeholder text displayed for an empty comment. */
setPlaceholderText(text: string) {
this.textArea.placeholder = text;
this.commentEditor.setPlaceholderText(text);
}
/** Registers a callback that listens for text changes. */
/** Registers a callback that listens for text changes on the comment editor. */
addTextChangeListener(listener: (oldText: string, newText: string) => void) {
this.textChangeListeners.push(listener);
this.commentEditor.addTextChangeListener(listener);
}
/** Removes the given listener from the list of text change listeners. */
/** Removes the given listener from the comment editor. */
removeTextChangeListener(listener: () => void) {
this.textChangeListeners.splice(
this.textChangeListeners.indexOf(listener),
1,
);
}
/**
* Triggers listeners when the text of the comment changes, either
* programmatically or manually by the user.
*/
private onTextChange() {
const oldText = this.text;
this.text = this.textArea.value;
this.updateTextPreview(this.text);
// Update size in case our minimum size increased.
this.setSize(this.size);
// Loop through listeners backwards in case they remove themselves.
for (let i = this.textChangeListeners.length - 1; i >= 0; i--) {
this.textChangeListeners[i](oldText, this.text);
}
this.commentEditor.removeTextChangeListener(listener);
}
/** Updates the preview text element to reflect the given text. */
@@ -734,7 +585,7 @@ export class CommentView implements IRenderedElement {
}
/** Brings the workspace comment to the front of its layer. */
private bringToFront() {
bringToFront() {
const parent = this.svgRoot.parentNode;
const childNodes = parent!.childNodes;
// Avoid moving the comment if it's already at the bottom.
@@ -761,6 +612,8 @@ 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--) {
@@ -791,6 +644,13 @@ export class CommentView implements IRenderedElement {
removeDisposeListener(listener: () => void) {
this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1);
}
/**
* @internal
*/
getCommentBarButtons(): CommentBarButton[] {
return [this.foldoutButton, this.deleteButton];
}
}
css.register(`
@@ -884,6 +744,11 @@ css.register(`
fill: none;
}
.blocklyCommentText.blocklyActiveFocus {
border-color: #fc3;
border-width: 2px;
}
.blocklySelected .blocklyCommentHighlight {
stroke: #fc3;
stroke-width: 3px;

View File

@@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as browserEvents from '../browser_events.js';
import {getFocusManager} from '../focus_manager.js';
import * as touch from '../touch.js';
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';
/**
* Magic string appended to the comment ID to create a unique ID for this button.
*/
export const COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER = '_delete_bar_button';
/**
* Button that deletes a comment.
*/
export class DeleteCommentBarButton extends CommentBarButton {
/**
* Opaque ID used to unbind event handlers during disposal.
*/
private readonly bindId: browserEvents.Data;
/**
* SVG image displayed on this button.
*/
protected override readonly icon: SVGImageElement;
/**
* Creates a new DeleteCommentBarButton instance.
*
* @param id The ID of this button's parent comment.
* @param workspace The workspace this button's parent comment is shown on.
* @param container An SVG group that this button should be a child of.
*/
constructor(
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
) {
super(id, workspace, container);
this.icon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyDeleteIcon',
'href': `${this.workspace.options.pathToMedia}delete-icon.svg`,
'id': `${this.id}${COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
},
container,
);
this.bindId = browserEvents.conditionalBind(
this.icon,
'pointerdown',
this,
this.performAction.bind(this),
);
}
/**
* Disposes of this button.
*/
dispose() {
browserEvents.unbind(this.bindId);
}
/**
* Adjusts the positioning of this button within its container.
*/
override reposition() {
const margin = this.getMargin();
// Reset to 0 so that our position doesn't force the parent container to
// grow.
this.icon.setAttribute('x', `0`);
const containerSize = this.container.getBBox();
this.icon.setAttribute('y', `${margin}`);
this.icon.setAttribute(
'x',
`${containerSize.width - this.getSize(true).getWidth()}`,
);
}
/**
* Deletes parent comment.
*
* @param e The event that triggered this action.
*/
override performAction(e?: Event) {
touch.clearTouchIdentifier();
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
this.getParentComment().dispose();
e?.stopPropagation();
getFocusManager().focusNode(this.workspace);
}
}

View File

@@ -47,7 +47,7 @@ export class RenderedWorkspaceComment
IFocusableNode
{
/** The class encompassing the svg elements making up the workspace comment. */
private view: CommentView;
view: CommentView;
public readonly workspace: WorkspaceSvg;
@@ -59,7 +59,7 @@ export class RenderedWorkspaceComment
this.workspace = workspace;
this.view = new CommentView(workspace);
this.view = new CommentView(workspace, this.id);
// Set the size to the default size as defined in the superclass.
this.view.setSize(this.getSize());
this.view.setEditable(this.isEditable());
@@ -224,13 +224,7 @@ export class RenderedWorkspaceComment
private startGesture(e: PointerEvent) {
const gesture = this.workspace.getGesture(e);
if (gesture) {
if (browserEvents.isTargetInput(e)) {
// If the text area was the focus, don't allow this event to bubble up
// and steal focus away from the editor/comment.
e.stopPropagation();
} else {
gesture.handleCommentStart(e, this);
}
gesture.handleCommentStart(e, this);
getFocusManager().focusNode(this);
}
}
@@ -244,6 +238,11 @@ export class RenderedWorkspaceComment
}
}
/** Returns whether this comment is copyable or not */
isCopyable(): boolean {
return this.isOwnMovable() && this.isOwnDeletable();
}
/** Returns whether this comment is movable or not. */
isMovable(): boolean {
return this.dragStrategy.isMovable();
@@ -334,6 +333,13 @@ export class RenderedWorkspaceComment
}
}
/**
* @returns The FocusableNode representing the editor portion of this comment.
*/
getEditorFocusableNode(): IFocusableNode {
return this.view.getEditorFocusableNode();
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
return this.getSvgRoot();

View File

@@ -165,7 +165,11 @@ export class WorkspaceComment {
* workspace is read-only.
*/
isMovable() {
return this.isOwnMovable() && !this.workspace.isReadOnly();
return (
this.isOwnMovable() &&
!this.workspace.isReadOnly() &&
!this.workspace.isFlyout
);
}
/**
@@ -189,7 +193,8 @@ export class WorkspaceComment {
return (
this.isOwnDeletable() &&
!this.isDeadOrDying() &&
!this.workspace.isReadOnly()
!this.workspace.isReadOnly() &&
!this.workspace.isFlyout
);
}

View File

@@ -320,21 +320,28 @@ export function defineBlocks(blocks: {[key: string]: BlockDefinition}) {
* @param e Key down event.
*/
export function globalShortcutHandler(e: KeyboardEvent) {
const mainWorkspace = getMainWorkspace() as WorkspaceSvg;
if (!mainWorkspace) {
return;
// This would ideally just be a `focusedTree instanceof WorkspaceSvg`, but
// importing `WorkspaceSvg` (as opposed to just its type) causes cycles.
let workspace: WorkspaceSvg = getMainWorkspace() as WorkspaceSvg;
const focusedTree = getFocusManager().getFocusedTree();
for (const ws of getAllWorkspaces()) {
if (focusedTree === (ws as WorkspaceSvg)) {
workspace = ws as WorkspaceSvg;
break;
}
}
if (
browserEvents.isTargetInput(e) ||
(mainWorkspace.rendered && !mainWorkspace.isVisible())
!workspace ||
(workspace.rendered && !workspace.isFlyout && !workspace.isVisible())
) {
// When focused on an HTML text input widget, don't trap any keys.
// Ignore keypresses on rendered workspaces that have been explicitly
// hidden.
return;
}
ShortcutRegistry.registry.onKeyDown(mainWorkspace, e);
ShortcutRegistry.registry.onKeyDown(workspace, e);
}
export const TEST_ONLY = {defineBlocksWithJsonArrayInternal};

View File

@@ -83,6 +83,12 @@ export class Connection {
public type: number,
) {
this.sourceBlock_ = source;
if (source.id.includes('_connection')) {
throw new Error(
`Connection ID indicator is contained in block ID. This will cause ` +
`problems with focus: ${source.id}.`,
);
}
this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`;
}

View File

@@ -265,6 +265,12 @@ export abstract class Field<T = any>
throw Error('Field already bound to a block');
}
this.sourceBlock_ = block;
if (block.id.includes('_field')) {
throw new Error(
`Field ID indicator is contained in block ID. This may cause ` +
`problems with focus: ${block.id}.`,
);
}
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
}

View File

@@ -27,6 +27,7 @@ import {
FieldValidator,
UnattachedFieldError,
} from './field.js';
import {getFocusManager} from './focus_manager.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import {Msg} from './msg.js';
import * as renderManagement from './render_management.js';
@@ -83,8 +84,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
/** Key down event data. */
private onKeyDownWrapper: browserEvents.Data | null = null;
/** Key input event data. */
private onKeyInputWrapper: browserEvents.Data | null = null;
/** Input element input event data. */
private onInputWrapper: browserEvents.Data | null = null;
/**
* Whether the field should consider the whole parent block to be its click
@@ -558,7 +559,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
this.onHtmlInputKeyDown_,
);
// Resize after every input change.
this.onKeyInputWrapper = browserEvents.conditionalBind(
this.onInputWrapper = browserEvents.conditionalBind(
htmlInput,
'input',
this,
@@ -572,9 +573,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
browserEvents.unbind(this.onKeyDownWrapper);
this.onKeyDownWrapper = null;
}
if (this.onKeyInputWrapper) {
browserEvents.unbind(this.onKeyInputWrapper);
this.onKeyInputWrapper = null;
if (this.onInputWrapper) {
browserEvents.unbind(this.onInputWrapper);
this.onInputWrapper = null;
}
}
@@ -614,6 +615,14 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (target instanceof FieldInput) {
WidgetDiv.hideIfOwner(this);
dropDownDiv.hideWithoutAnimation();
const targetSourceBlock = target.getSourceBlock();
if (
target.isFullBlockField() &&
targetSourceBlock &&
targetSourceBlock instanceof BlockSvg
) {
getFocusManager().focusNode(targetSourceBlock);
} else getFocusManager().focusNode(target);
target.showEditor();
}
}
@@ -622,7 +631,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
/**
* Handle a change to the editor.
*
* @param _e Keyboard event.
* @param _e InputEvent.
*/
private onHtmlInputChange(_e: Event) {
// Intermediate value changes from user input are not confirmed until the

View File

@@ -174,8 +174,15 @@ export class FocusManager {
this.registeredTrees.push(
new TreeRegistration(tree, rootShouldBeAutoTabbable),
);
const rootElement = tree.getRootFocusableNode().getFocusableElement();
if (!rootElement.id || rootElement.id === 'null') {
throw Error(
`Attempting to register a tree with a root element that has an ` +
`invalid ID: ${tree}.`,
);
}
if (rootShouldBeAutoTabbable) {
tree.getRootFocusableNode().getFocusableElement().tabIndex = 0;
rootElement.tabIndex = 0;
}
}
@@ -344,13 +351,22 @@ export class FocusManager {
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
}
const focusableNodeElement = focusableNode.getFocusableElement();
if (!focusableNodeElement.id || focusableNodeElement.id === 'null') {
// Warn that the ID is invalid, but continue execution since an invalid ID
// will result in an unmatched (null) node. Since a request to focus
// something was initiated, the code below will attempt to find the next
// best thing to focus, instead.
console.warn('Trying to focus a node that has an invalid ID.');
}
// Safety check for ensuring focusNode() doesn't get called for a node that
// isn't actually hooked up to its parent tree correctly. This usually
// happens when calls to focusNode() interleave with asynchronous clean-up
// operations (which can happen due to ephemeral focus and in other cases).
// Fall back to a reasonable default since there's no valid node to focus.
const matchedNode = FocusableTreeTraverser.findFocusableNodeFor(
focusableNode.getFocusableElement(),
focusableNodeElement,
nextTree,
);
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);

View File

@@ -14,7 +14,6 @@ import {BlockChange} from '../events/events_block_change.js';
import {isBlockChange, isBlockCreate} from '../events/predicates.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 * as renderManagement from '../render_management.js';
import {Coordinate} from '../utils/coordinate.js';
@@ -205,7 +204,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
}
/** See IHasBubble.getBubble. */
getBubble(): IBubble | null {
getBubble(): MiniWorkspaceBubble | null {
return this.miniWorkspaceBubble;
}

View File

@@ -23,5 +23,5 @@ export interface IAutoHideable extends IComponent {
/** Returns true if the given object is autohideable. */
export function isAutoHideable(obj: any): obj is IAutoHideable {
return obj.autoHide !== undefined;
return obj && typeof obj.autoHide === 'function';
}

View File

@@ -31,17 +31,17 @@ export interface ICommentIcon extends IIcon, IHasBubble, ISerializable {
}
/** Checks whether the given object is an ICommentIcon. */
export function isCommentIcon(obj: object): obj is ICommentIcon {
export function isCommentIcon(obj: any): obj is ICommentIcon {
return (
isIcon(obj) &&
hasBubble(obj) &&
isSerializable(obj) &&
(obj as any)['setText'] !== undefined &&
(obj as any)['getText'] !== undefined &&
(obj as any)['setBubbleSize'] !== undefined &&
(obj as any)['getBubbleSize'] !== undefined &&
(obj as any)['setBubbleLocation'] !== undefined &&
(obj as any)['getBubbleLocation'] !== undefined &&
typeof (obj as any).setText === 'function' &&
typeof (obj as any).getText === 'function' &&
typeof (obj as any).setBubbleSize === 'function' &&
typeof (obj as any).getBubbleSize === 'function' &&
typeof (obj as any).setBubbleLocation === 'function' &&
typeof (obj as any).getBubbleLocation === 'function' &&
obj.getType() === IconType.COMMENT
);
}

View File

@@ -15,6 +15,14 @@ export interface ICopyable<T extends ICopyData> extends ISelectable {
* @returns Copy metadata.
*/
toCopyData(): T | null;
/**
* Whether this instance is currently copyable. The standard implementation
* is to return true if isOwnDeletable and isOwnMovable return true.
*
* @returns True if it can currently be copied.
*/
isCopyable?(): boolean;
}
export namespace ICopyable {
@@ -25,7 +33,7 @@ export namespace ICopyable {
export type ICopyData = ICopyable.ICopyData;
/** @returns true if the given object is copyable. */
/** @returns true if the given object is an ICopyable. */
export function isCopyable(obj: any): obj is ICopyable<ICopyData> {
return obj.toCopyData !== undefined;
return obj && typeof obj.toCopyData === 'function';
}

View File

@@ -27,8 +27,9 @@ export interface IDeletable {
/** Returns whether the given object is an IDeletable. */
export function isDeletable(obj: any): obj is IDeletable {
return (
obj['isDeletable'] !== undefined &&
obj['dispose'] !== undefined &&
obj['setDeleteStyle'] !== undefined
obj &&
typeof obj.isDeletable === 'function' &&
typeof obj.dispose === 'function' &&
typeof obj.setDeleteStyle === 'function'
);
}

View File

@@ -62,11 +62,12 @@ export interface IDragStrategy {
/** Returns whether the given object is an IDraggable or not. */
export function isDraggable(obj: any): obj is IDraggable {
return (
obj.getRelativeToSurfaceXY !== undefined &&
obj.isMovable !== undefined &&
obj.startDrag !== undefined &&
obj.drag !== undefined &&
obj.endDrag !== undefined &&
obj.revertDrag !== undefined
obj &&
typeof obj.getRelativeToSurfaceXY === 'function' &&
typeof obj.isMovable === 'function' &&
typeof obj.startDrag === 'function' &&
typeof obj.drag === 'function' &&
typeof obj.endDrag === 'function' &&
typeof obj.revertDrag === 'function'
);
}

View File

@@ -102,16 +102,16 @@ export interface IFocusableNode {
* Determines whether the provided object fulfills the contract of
* IFocusableNode.
*
* @param object The object to test.
* @param obj The object to test.
* @returns Whether the provided object can be used as an IFocusableNode.
*/
export function isFocusableNode(object: any | null): object is IFocusableNode {
export function isFocusableNode(obj: any): obj is IFocusableNode {
return (
object &&
'getFocusableElement' in object &&
'getFocusableTree' in object &&
'onNodeFocus' in object &&
'onNodeBlur' in object &&
'canBeFocused' in object
obj &&
typeof obj.getFocusableElement === 'function' &&
typeof obj.getFocusableTree === 'function' &&
typeof obj.onNodeFocus === 'function' &&
typeof obj.onNodeBlur === 'function' &&
typeof obj.canBeFocused === 'function'
);
}

View File

@@ -128,17 +128,17 @@ export interface IFocusableTree {
* Determines whether the provided object fulfills the contract of
* IFocusableTree.
*
* @param object The object to test.
* @param obj The object to test.
* @returns Whether the provided object can be used as an IFocusableTree.
*/
export function isFocusableTree(object: any | null): object is IFocusableTree {
export function isFocusableTree(obj: any): obj is IFocusableTree {
return (
object &&
'getRootFocusableNode' in object &&
'getRestoredFocusableNode' in object &&
'getNestedTrees' in object &&
'lookUpFocusableNode' in object &&
'onTreeFocus' in object &&
'onTreeBlur' in object
obj &&
typeof obj.getRootFocusableNode === 'function' &&
typeof obj.getRestoredFocusableNode === 'function' &&
typeof obj.getNestedTrees === 'function' &&
typeof obj.lookUpFocusableNode === 'function' &&
typeof obj.onTreeFocus === 'function' &&
typeof obj.onTreeBlur === 'function'
);
}

View File

@@ -30,6 +30,8 @@ export interface IHasBubble {
/** Type guard that checks whether the given object is a IHasBubble. */
export function hasBubble(obj: any): obj is IHasBubble {
return (
obj.bubbleIsVisible !== undefined && obj.setBubbleVisible !== undefined
typeof obj.bubbleIsVisible === 'function' &&
typeof obj.setBubbleVisible === 'function' &&
typeof obj.getBubble === 'function'
);
}

View File

@@ -98,19 +98,19 @@ export interface IIcon extends IFocusableNode {
/** Type guard that checks whether the given object is an IIcon. */
export function isIcon(obj: any): obj is IIcon {
return (
obj.getType !== undefined &&
obj.initView !== undefined &&
obj.dispose !== undefined &&
obj.getWeight !== undefined &&
obj.getSize !== undefined &&
obj.applyColour !== undefined &&
obj.hideForInsertionMarker !== undefined &&
obj.updateEditable !== undefined &&
obj.updateCollapsed !== undefined &&
obj.isShownWhenCollapsed !== undefined &&
obj.setOffsetInBlock !== undefined &&
obj.onLocationChange !== undefined &&
obj.onClick !== undefined &&
isFocusableNode(obj)
isFocusableNode(obj) &&
typeof (obj as IIcon).getType === 'function' &&
typeof (obj as IIcon).initView === 'function' &&
typeof (obj as IIcon).dispose === 'function' &&
typeof (obj as IIcon).getWeight === 'function' &&
typeof (obj as IIcon).getSize === 'function' &&
typeof (obj as IIcon).applyColour === 'function' &&
typeof (obj as IIcon).hideForInsertionMarker === 'function' &&
typeof (obj as IIcon).updateEditable === 'function' &&
typeof (obj as IIcon).updateCollapsed === 'function' &&
typeof (obj as IIcon).isShownWhenCollapsed === 'function' &&
typeof (obj as IIcon).setOffsetInBlock === 'function' &&
typeof (obj as IIcon).onLocationChange === 'function' &&
typeof (obj as IIcon).onClick === 'function'
);
}

View File

@@ -28,9 +28,9 @@ export interface LegacyProcedureDefBlock {
/** @internal */
export function isLegacyProcedureDefBlock(
block: object,
): block is LegacyProcedureDefBlock {
return (block as any).getProcedureDef !== undefined;
obj: any,
): obj is LegacyProcedureDefBlock {
return obj && typeof obj.getProcedureDef === 'function';
}
/** @internal */
@@ -41,10 +41,11 @@ export interface LegacyProcedureCallBlock {
/** @internal */
export function isLegacyProcedureCallBlock(
block: object,
): block is LegacyProcedureCallBlock {
obj: any,
): obj is LegacyProcedureCallBlock {
return (
(block as any).getProcedureCall !== undefined &&
(block as any).renameProcedure !== undefined
obj &&
typeof obj.getProcedureCall === 'function' &&
typeof obj.renameProcedure === 'function'
);
}

View File

@@ -20,5 +20,9 @@ export interface IObservable {
* @internal
*/
export function isObservable(obj: any): obj is IObservable {
return obj.startPublishing !== undefined && obj.stopPublishing !== undefined;
return (
obj &&
typeof obj.startPublishing === 'function' &&
typeof obj.stopPublishing === 'function'
);
}

View File

@@ -21,5 +21,5 @@ export interface IPaster<U extends ICopyData, T extends ICopyable<U>> {
export function isPaster(
obj: any,
): obj is IPaster<ICopyData, ICopyable<ICopyData>> {
return obj.paste !== undefined;
return obj && typeof obj.paste === 'function';
}

View File

@@ -20,9 +20,10 @@ export interface IProcedureBlock {
export function isProcedureBlock(
block: Block | IProcedureBlock,
): block is IProcedureBlock {
block = block as IProcedureBlock;
return (
(block as IProcedureBlock).getProcedureModel !== undefined &&
(block as IProcedureBlock).doProcedureUpdate !== undefined &&
(block as IProcedureBlock).isProcedureDef !== undefined
typeof block.getProcedureModel === 'function' &&
typeof block.doProcedureUpdate === 'function' &&
typeof block.isProcedureDef === 'function'
);
}

View File

@@ -15,5 +15,5 @@ export interface IRenderedElement {
* @returns True if the given object is an IRenderedElement.
*/
export function isRenderedElement(obj: any): obj is IRenderedElement {
return obj['getSvgRoot'] !== undefined;
return obj && typeof obj.getSvgRoot === 'function';
}

View File

@@ -30,12 +30,12 @@ export interface ISelectable extends IFocusableNode {
}
/** Checks whether the given object is an ISelectable. */
export function isSelectable(obj: object): obj is ISelectable {
export function isSelectable(obj: any): obj is ISelectable {
return (
typeof (obj as any).id === 'string' &&
(obj as any).workspace !== undefined &&
(obj as any).select !== undefined &&
(obj as any).unselect !== undefined &&
isFocusableNode(obj)
isFocusableNode(obj) &&
typeof (obj as ISelectable).id === 'string' &&
typeof (obj as ISelectable).workspace === 'object' &&
typeof (obj as ISelectable).select === 'function' &&
typeof (obj as ISelectable).unselect === 'function'
);
}

View File

@@ -24,5 +24,9 @@ export interface ISerializable {
/** Type guard that checks whether the given object is a ISerializable. */
export function isSerializable(obj: any): obj is ISerializable {
return obj.saveState !== undefined && obj.loadState !== undefined;
return (
obj &&
typeof obj.saveState === 'function' &&
typeof obj.loadState === 'function'
);
}

View File

@@ -8,8 +8,11 @@ import {BlockSvg} from '../block_svg.js';
import {ConnectionType} from '../connection_type.js';
import type {Field} from '../field.js';
import type {Icon} from '../icons/icon.js';
import type {IBoundedElement} from '../interfaces/i_bounded_element.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import type {ISelectable} from '../interfaces/i_selectable.js';
import {RenderedConnection} from '../rendered_connection.js';
import {WorkspaceSvg} from '../workspace_svg.js';
@@ -24,7 +27,7 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
* @returns The first field or input of the given block, if any.
*/
getFirstChild(current: BlockSvg): IFocusableNode | null {
const candidates = getBlockNavigationCandidates(current);
const candidates = getBlockNavigationCandidates(current, true);
return candidates[0];
}
@@ -58,6 +61,8 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
return current.nextConnection?.targetBlock();
} else if (current.outputConnection?.targetBlock()) {
return navigateBlock(current, 1);
} else if (current.getSurroundParent()) {
return navigateBlock(current.getTopStackBlock(), 1);
} else if (this.getParent(current) instanceof WorkspaceSvg) {
return navigateStacks(current, 1);
}
@@ -111,14 +116,27 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
* @param block The block to retrieve the navigable children of.
* @returns A list of navigable/focusable children of the given block.
*/
function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] {
function getBlockNavigationCandidates(
block: BlockSvg,
forward: boolean,
): IFocusableNode[] {
const candidates: IFocusableNode[] = block.getIcons();
for (const input of block.inputList) {
if (!input.isVisible()) continue;
candidates.push(...input.fieldRow);
if (input.connection?.targetBlock()) {
candidates.push(input.connection.targetBlock() as BlockSvg);
const connectedBlock = input.connection.targetBlock() as BlockSvg;
if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
const lastStackBlock = connectedBlock
.lastConnectionInStack(false)
?.getSourceBlock();
if (lastStackBlock) {
candidates.push(lastStackBlock);
}
} else {
candidates.push(connectedBlock);
}
} else if (input.connection?.type === ConnectionType.INPUT_VALUE) {
candidates.push(input.connection as RenderedConnection);
}
@@ -128,21 +146,25 @@ function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] {
}
/**
* Returns the next/previous stack relative to the given block's stack.
* Returns the next/previous stack relative to the given element's stack.
*
* @param current The block whose stack will be navigated relative to.
* @param current The element whose stack will be navigated relative to.
* @param delta The difference in index to navigate; positive values navigate
* to the nth next stack, while negative values navigate to the nth previous
* stack.
* @returns The first block in the stack offset by `delta` relative to the
* current block's stack, or the last block in the stack offset by `delta`
* relative to the current block's stack when navigating backwards.
* @returns The first element in the stack offset by `delta` relative to the
* current element's stack, or the last element in the stack offset by
* `delta` relative to the current element's stack when navigating backwards.
*/
export function navigateStacks(current: BlockSvg, delta: number) {
const stacks = current.workspace.getTopBlocks(true);
const currentIndex = stacks.indexOf(current.getRootBlock());
export function navigateStacks(current: ISelectable, delta: number) {
const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg)
.getTopBoundedElements(true)
.filter((element: IBoundedElement) => isFocusableNode(element));
const currentIndex = stacks.indexOf(
current instanceof BlockSvg ? current.getRootBlock() : current,
);
const targetIndex = currentIndex + delta;
let result: BlockSvg | null = null;
let result: IFocusableNode | null = null;
if (targetIndex >= 0 && targetIndex < stacks.length) {
result = stacks[targetIndex];
} else if (targetIndex < 0) {
@@ -151,9 +173,9 @@ export function navigateStacks(current: BlockSvg, delta: number) {
result = stacks[0];
}
// When navigating to a previous stack, our previous sibling is the last
// When navigating to a previous block stack, our previous sibling is the last
// block in it.
if (delta < 0 && result) {
if (delta < 0 && result instanceof BlockSvg) {
return result.lastConnectionInStack(false)?.getSourceBlock() ?? result;
}
@@ -174,11 +196,11 @@ export function navigateBlock(
): IFocusableNode | null {
const block =
current instanceof BlockSvg
? current.outputConnection.targetBlock()
? (current.outputConnection?.targetBlock() ?? current.getSurroundParent())
: current.getSourceBlock();
if (!(block instanceof BlockSvg)) return null;
const candidates = getBlockNavigationCandidates(block);
const candidates = getBlockNavigationCandidates(block, delta > 0);
const currentIndex = candidates.indexOf(current);
if (currentIndex === -1) return null;

View File

@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentBarButton} from '../comments/comment_bar_button.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 CommentBarButton.
*/
export class CommentBarButtonNavigationPolicy
implements INavigationPolicy<CommentBarButton>
{
/**
* Returns the first child of the given CommentBarButton.
*
* @param _current The CommentBarButton to return the first child of.
* @returns Null.
*/
getFirstChild(_current: CommentBarButton): IFocusableNode | null {
return null;
}
/**
* Returns the parent of the given CommentBarButton.
*
* @param current The CommentBarButton to return the parent of.
* @returns The parent comment of the given CommentBarButton.
*/
getParent(current: CommentBarButton): IFocusableNode | null {
return current.getParentComment();
}
/**
* Returns the next peer node of the given CommentBarButton.
*
* @param current The CommentBarButton to find the following element of.
* @returns The next CommentBarButton, if any.
*/
getNextSibling(current: CommentBarButton): IFocusableNode | null {
const children = current.getParentComment().view.getCommentBarButtons();
const currentIndex = children.indexOf(current);
if (currentIndex >= 0 && currentIndex + 1 < children.length) {
return children[currentIndex + 1];
}
return null;
}
/**
* Returns the previous peer node of the given CommentBarButton.
*
* @param current The CommentBarButton to find the preceding element of.
* @returns The CommentBarButton's previous CommentBarButton, if any.
*/
getPreviousSibling(current: CommentBarButton): IFocusableNode | null {
const children = current.getParentComment().view.getCommentBarButtons();
const currentIndex = children.indexOf(current);
if (currentIndex > 0) {
return children[currentIndex - 1];
}
return null;
}
/**
* Returns whether or not the given CommentBarButton can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given CommentBarButton can be focused.
*/
isNavigable(current: CommentBarButton): 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 CommentBarButton.
*/
isApplicable(current: any): current is CommentBarButton {
return current instanceof CommentBarButton;
}
}

View File

@@ -5,7 +5,9 @@
*/
import {BlockSvg} from '../block_svg.js';
import {getFocusManager} from '../focus_manager.js';
import {Icon} from '../icons/icon.js';
import {MutatorIcon} from '../icons/mutator_icon.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import {navigateBlock} from './block_navigation_policy.js';
@@ -17,10 +19,18 @@ export class IconNavigationPolicy implements INavigationPolicy<Icon> {
/**
* Returns the first child of the given icon.
*
* @param _current The icon to return the first child of.
* @param current The icon to return the first child of.
* @returns Null.
*/
getFirstChild(_current: Icon): IFocusableNode | null {
getFirstChild(current: Icon): IFocusableNode | null {
if (
current instanceof MutatorIcon &&
current.bubbleIsVisible() &&
getFocusManager().getFocusedNode() === current
) {
return current.getBubble()?.getWorkspace() ?? null;
}
return null;
}

View File

@@ -14,10 +14,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 {isFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {Marker} from './marker.js';
@@ -39,11 +39,11 @@ export class LineCursor extends Marker {
}
/**
* Moves the cursor to the next previous connection, next connection or block
* in the pre order traversal. Finds the next node in the pre order traversal.
* Moves the cursor to the next block or workspace comment in the pre-order
* traversal.
*
* @returns The next node, or null if the current node is
* not set or there is no next value.
* @returns The next node, or null if the current node is not set or there is
* no next value.
*/
next(): IFocusableNode | null {
const curNode = this.getCurNode();
@@ -54,8 +54,9 @@ export class LineCursor extends Marker {
curNode,
(candidate: IFocusableNode | null) => {
return (
candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()
(candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()) ||
candidate instanceof RenderedWorkspaceComment
);
},
true,
@@ -88,11 +89,11 @@ export class LineCursor extends Marker {
return newNode;
}
/**
* Moves the cursor to the previous next connection or previous connection in
* the pre order traversal.
* Moves the cursor to the previous block or workspace comment in the
* pre-order traversal.
*
* @returns The previous node, or null if the current node
* is not set or there is no previous value.
* @returns The previous node, or null if the current node is not set or there
* is no previous value.
*/
prev(): IFocusableNode | null {
const curNode = this.getCurNode();
@@ -103,8 +104,9 @@ export class LineCursor extends Marker {
curNode,
(candidate: IFocusableNode | null) => {
return (
candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()
(candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()) ||
candidate instanceof RenderedWorkspaceComment
);
},
true,
@@ -374,17 +376,8 @@ export class LineCursor extends Marker {
*
* @returns The current field, connection, or block the cursor is on.
*/
override getCurNode(): IFocusableNode | null {
// Ensure the current node matches what's currently focused.
const focused = getFocusManager().getFocusedNode();
const block = this.getSourceBlockFromNode(focused);
if (block && block.workspace === this.workspace) {
// If the current focused node corresponds to a block then ensure that it
// belongs to the correct workspace for this cursor.
this.setCurNode(focused);
}
return super.getCurNode();
getCurNode(): IFocusableNode | null {
return getFocusManager().getFocusedNode();
}
/**
@@ -395,12 +388,8 @@ export class LineCursor extends Marker {
*
* @param newNode The new location of the cursor.
*/
override setCurNode(newNode: IFocusableNode | null) {
super.setCurNode(newNode);
if (isFocusableNode(newNode)) {
getFocusManager().focusNode(newNode);
}
setCurNode(newNode: IFocusableNode) {
getFocusManager().focusNode(newNode);
// Try to scroll cursor into view.
if (newNode instanceof BlockSvg) {
@@ -412,6 +401,8 @@ export class LineCursor extends Marker {
block.workspace.scrollBoundsIntoView(
block.getBoundingRectangleWithoutChildren(),
);
} else if (newNode instanceof RenderedWorkspaceComment) {
newNode.workspace.scrollBoundsIntoView(newNode.getBoundingRectangle());
}
}

View File

@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import {navigateStacks} from './block_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from an RenderedWorkspaceComment.
*/
export class WorkspaceCommentNavigationPolicy
implements INavigationPolicy<RenderedWorkspaceComment>
{
/**
* Returns the first child of the given workspace comment.
*
* @param current The workspace comment to return the first child of.
* @returns The first child button of the given comment.
*/
getFirstChild(current: RenderedWorkspaceComment): IFocusableNode | null {
return current.view.getCommentBarButtons()[0];
}
/**
* Returns the parent of the given workspace comment.
*
* @param current The workspace comment to return the parent of.
* @returns The parent workspace of the given comment.
*/
getParent(current: RenderedWorkspaceComment): IFocusableNode | null {
return current.workspace;
}
/**
* Returns the next peer node of the given workspace comment.
*
* @param current The workspace comment to find the following element of.
* @returns The next workspace comment or block stack, if any.
*/
getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null {
return navigateStacks(current, 1);
}
/**
* Returns the previous peer node of the given workspace comment.
*
* @param current The workspace comment to find the preceding element of.
* @returns The previous workspace comment or block stack, if any.
*/
getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null {
return navigateStacks(current, -1);
}
/**
* Returns whether or not the given workspace comment can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given workspace comment can be focused.
*/
isNavigable(current: RenderedWorkspaceComment): 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 RenderedWorkspaceComment.
*/
isApplicable(current: any): current is RenderedWorkspaceComment {
return current instanceof RenderedWorkspaceComment;
}
}

View File

@@ -62,7 +62,7 @@ export class WorkspaceNavigationPolicy
* @returns True if the given workspace can be focused.
*/
isNavigable(current: WorkspaceSvg): boolean {
return current.canBeFocused();
return current.canBeFocused() && !current.isMutator;
}
/**

View File

@@ -11,7 +11,7 @@
*/
// Former goog.module ID: Blockly.MarkerManager
import type {LineCursor} from './keyboard_nav/line_cursor.js';
import {LineCursor} from './keyboard_nav/line_cursor.js';
import type {Marker} from './keyboard_nav/marker.js';
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -23,7 +23,7 @@ export class MarkerManager {
static readonly LOCAL_MARKER = 'local_marker_1';
/** The cursor. */
private cursor: LineCursor | null = null;
private cursor: LineCursor;
/** The map of markers for the workspace. */
private markers = new Map<string, Marker>();
@@ -32,7 +32,9 @@ export class MarkerManager {
* @param workspace The workspace for the marker manager.
* @internal
*/
constructor(private readonly workspace: WorkspaceSvg) {}
constructor(private readonly workspace: WorkspaceSvg) {
this.cursor = new LineCursor(this.workspace);
}
/**
* Register the marker by adding it to the map of markers.
@@ -72,7 +74,7 @@ export class MarkerManager {
*
* @returns The cursor for this workspace.
*/
getCursor(): LineCursor | null {
getCursor(): LineCursor {
return this.cursor;
}
@@ -109,9 +111,6 @@ export class MarkerManager {
this.unregisterMarker(markerId);
}
this.markers.clear();
if (this.cursor) {
this.cursor.dispose();
this.cursor = null;
}
this.cursor.dispose();
}
}

View File

@@ -7,9 +7,11 @@
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {INavigationPolicy} from './interfaces/i_navigation_policy.js';
import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js';
import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_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';
import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js';
import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js';
type RuleList<T> = INavigationPolicy<T>[];
@@ -29,6 +31,8 @@ export class Navigator {
new ConnectionNavigationPolicy(),
new WorkspaceNavigationPolicy(),
new IconNavigationPolicy(),
new WorkspaceCommentNavigationPolicy(),
new CommentBarButtonNavigationPolicy(),
];
/**
@@ -64,9 +68,8 @@ export class Navigator {
getFirstChild(current: IFocusableNode): IFocusableNode | null {
const result = this.get(current)?.getFirstChild(current);
if (!result) return null;
// If the child isn't navigable, don't traverse into it; check its peers.
if (!this.get(result)?.isNavigable(result)) {
return this.getNextSibling(result);
return this.getFirstChild(result) || this.getNextSibling(result);
}
return result;
}

View File

@@ -8,19 +8,12 @@
import {BlockSvg} from './block_svg.js';
import * as clipboard from './clipboard.js';
import {RenderedWorkspaceComment} from './comments.js';
import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js';
import {Gesture} from './gesture.js';
import {
ICopyable,
ICopyData,
isCopyable as isICopyable,
} from './interfaces/i_copyable.js';
import {
IDeletable,
isDeletable as isIDeletable,
} from './interfaces/i_deletable.js';
import {IDraggable, isDraggable} from './interfaces/i_draggable.js';
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
import {isDraggable} from './interfaces/i_draggable.js';
import {IFocusableNode} from './interfaces/i_focusable_node.js';
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {Coordinate} from './utils/coordinate.js';
@@ -73,7 +66,7 @@ export function registerDelete() {
focused != null &&
isIDeletable(focused) &&
focused.isDeletable() &&
!Gesture.inProgress() &&
!workspace.isDragging() &&
// Don't delete the block if a field editor is open
!getFocusManager().ephemeralFocusTaken()
);
@@ -99,75 +92,41 @@ export function registerDelete() {
ShortcutRegistry.registry.register(deleteShortcut);
}
let copyData: ICopyData | null = null;
let copyWorkspace: WorkspaceSvg | null = null;
let copyCoords: Coordinate | null = null;
/**
* Determine if a focusable node can be copied.
*
* Unfortunately the ICopyable interface doesn't include an isCopyable
* method, so we must use some other criteria to make the decision.
* Specifically,
*
* - It must be an ICopyable.
* - So that a pasted copy can be manipluated and/or disposed of, it
* must be both an IDraggable and an IDeletable.
* - Additionally, both .isOwnMovable() and .isOwnDeletable() must return
* true (i.e., the copy could be moved and deleted).
*
* TODO(#9098): Revise these criteria. The latter criteria prevents
* shadow blocks from being copied; additionally, there are likely to
* be other circumstances were it is desirable to allow movable /
* copyable copies of a currently-unmovable / -copyable block to be
* made.
* This will use the isCopyable method if the node implements it, otherwise
* it will fall back to checking if the node is deletable and draggable not
* considering the workspace's edit state.
*
* @param focused The focused object.
*/
function isCopyable(
focused: IFocusableNode,
): focused is ICopyable<ICopyData> & IDeletable & IDraggable {
if (!(focused instanceof BlockSvg)) return false;
return (
isICopyable(focused) &&
isIDeletable(focused) &&
focused.isOwnDeletable() &&
isDraggable(focused) &&
focused.isOwnMovable()
);
function isCopyable(focused: IFocusableNode): boolean {
if (!isICopyable(focused) || !isIDeletable(focused) || !isDraggable(focused))
return false;
if (focused.isCopyable) {
return focused.isCopyable();
} else if (
focused instanceof BlockSvg ||
focused instanceof RenderedWorkspaceComment
) {
return focused.isOwnDeletable() && focused.isOwnMovable();
}
// This isn't a class Blockly knows about, so fall back to the stricter
// checks for deletable and movable.
return focused.isDeletable() && focused.isMovable();
}
/**
* Determine if a focusable node can be cut.
*
* Unfortunately the ICopyable interface doesn't include an isCuttable
* method, so we must use some other criteria to make the decision.
* Specifically,
*
* - It must be an ICopyable.
* - So that a pasted copy can be manipluated and/or disposed of, it
* must be both an IDraggable and an IDeletable.
* - Additionally, both .isMovable() and .isDeletable() must return
* true (i.e., can currently be moved and deleted). This is the main
* difference with isCopyable.
*
* TODO(#9098): Revise these criteria. The latter criteria prevents
* shadow blocks from being copied; additionally, there are likely to
* be other circumstances were it is desirable to allow movable /
* copyable copies of a currently-unmovable / -copyable block to be
* made.
* This will check if the node can be both copied and deleted in its current
* workspace.
*
* @param focused The focused object.
*/
function isCuttable(focused: IFocusableNode): boolean {
if (!(focused instanceof BlockSvg)) return false;
return (
isICopyable(focused) &&
isIDeletable(focused) &&
focused.isDeletable() &&
isDraggable(focused) &&
focused.isMovable()
);
return isCopyable(focused) && isIDeletable(focused) && focused.isDeletable();
}
/**
@@ -185,7 +144,6 @@ export function registerCopy() {
name: names.COPY,
preconditionFn(workspace, scope) {
const focused = scope.focusedNode;
if (!(focused instanceof BlockSvg)) return false;
const targetWorkspace = workspace.isFlyout
? workspace.targetWorkspace
@@ -193,7 +151,6 @@ export function registerCopy() {
return (
!!focused &&
!!targetWorkspace &&
!targetWorkspace.isReadOnly() &&
!targetWorkspace.isDragging() &&
!getFocusManager().ephemeralFocusTaken() &&
isCopyable(focused)
@@ -205,26 +162,22 @@ export function registerCopy() {
e.preventDefault();
const focused = scope.focusedNode;
if (!focused || !isCopyable(focused)) return false;
let targetWorkspace: WorkspaceSvg | null =
focused.workspace instanceof WorkspaceSvg
? focused.workspace
: workspace;
targetWorkspace = targetWorkspace.isFlyout
? targetWorkspace.targetWorkspace
: targetWorkspace;
if (!focused || !isICopyable(focused) || !isCopyable(focused))
return false;
const targetWorkspace = workspace.isFlyout
? workspace.targetWorkspace
: workspace;
if (!targetWorkspace) return false;
if (!focused.workspace.isFlyout) {
targetWorkspace.hideChaff();
}
copyData = focused.toCopyData();
copyWorkspace = targetWorkspace;
copyCoords =
const copyCoords =
isDraggable(focused) && focused.workspace == targetWorkspace
? focused.getRelativeToSurfaceXY()
: null;
return !!copyData;
: undefined;
return !!clipboard.copy(focused, copyCoords);
},
keyCodes: [ctrlC, metaC],
};
@@ -256,27 +209,20 @@ export function registerCut() {
},
callback(workspace, e, shortcut, scope) {
const focused = scope.focusedNode;
if (!focused || !isCuttable(focused) || !isICopyable(focused)) {
return false;
}
const copyCoords = isDraggable(focused)
? focused.getRelativeToSurfaceXY()
: undefined;
const copyData = clipboard.copy(focused, copyCoords);
if (focused instanceof BlockSvg) {
copyData = focused.toCopyData();
copyWorkspace = workspace;
copyCoords = focused.getRelativeToSurfaceXY();
focused.checkAndDelete();
return true;
} else if (
isIDeletable(focused) &&
focused.isDeletable() &&
isICopyable(focused)
) {
copyData = focused.toCopyData();
copyWorkspace = workspace;
copyCoords = isDraggable(focused)
? focused.getRelativeToSurfaceXY()
: null;
} else if (isIDeletable(focused)) {
focused.dispose();
return true;
}
return false;
return !!copyData;
},
keyCodes: [ctrlX, metaX],
};
@@ -297,12 +243,19 @@ export function registerPaste() {
const pasteShortcut: KeyboardShortcut = {
name: names.PASTE,
preconditionFn(workspace) {
preconditionFn() {
// Regardless of the currently focused workspace, we will only
// paste into the last-copied-from workspace.
const workspace = clipboard.getLastCopiedWorkspace();
// If we don't know where we copied from, we don't know where to paste.
// If the workspace isn't rendered (e.g. closed mutator workspace),
// we can't paste into it.
if (!workspace || !workspace.rendered) return false;
const targetWorkspace = workspace.isFlyout
? workspace.targetWorkspace
: workspace;
return (
!!copyData &&
!!clipboard.getLastCopiedData() &&
!!targetWorkspace &&
!targetWorkspace.isReadOnly() &&
!targetWorkspace.isDragging() &&
@@ -310,7 +263,16 @@ export function registerPaste() {
);
},
callback(workspace: WorkspaceSvg, e: Event) {
if (!copyData || !copyWorkspace) return false;
const copyData = clipboard.getLastCopiedData();
if (!copyData) return false;
const copyWorkspace = clipboard.getLastCopiedWorkspace();
if (!copyWorkspace) return false;
const targetWorkspace = copyWorkspace.isFlyout
? copyWorkspace.targetWorkspace
: copyWorkspace;
if (!targetWorkspace || targetWorkspace.isReadOnly()) return false;
if (e instanceof PointerEvent) {
// The event that triggers a shortcut would conventionally be a KeyboardEvent.
@@ -319,19 +281,20 @@ export function registerPaste() {
// at the mouse coordinates where the menu was opened, and this PointerEvent
// is where the menu was opened.
const mouseCoords = svgMath.screenToWsCoordinates(
copyWorkspace,
targetWorkspace,
new Coordinate(e.clientX, e.clientY),
);
return !!clipboard.paste(copyData, copyWorkspace, mouseCoords);
return !!clipboard.paste(copyData, targetWorkspace, mouseCoords);
}
const copyCoords = clipboard.getLastCopiedLocation();
if (!copyCoords) {
// If we don't have location data about the original copyable, let the
// paster determine position.
return !!clipboard.paste(copyData, copyWorkspace);
return !!clipboard.paste(copyData, targetWorkspace);
}
const {left, top, width, height} = copyWorkspace
const {left, top, width, height} = targetWorkspace
.getMetricsManager()
.getViewMetrics(true);
const viewportRect = new Rect(top, top + height, left, left + width);
@@ -339,12 +302,12 @@ export function registerPaste() {
if (viewportRect.contains(copyCoords.x, copyCoords.y)) {
// If the original copyable is inside the viewport, let the paster
// determine position.
return !!clipboard.paste(copyData, copyWorkspace);
return !!clipboard.paste(copyData, targetWorkspace);
}
// Otherwise, paste in the middle of the viewport.
const centerCoords = new Coordinate(left + width / 2, top + height / 2);
return !!clipboard.paste(copyData, copyWorkspace, centerCoords);
return !!clipboard.paste(copyData, targetWorkspace, centerCoords);
},
keyCodes: [ctrlV, metaV],
};
@@ -368,7 +331,7 @@ export function registerUndo() {
preconditionFn(workspace) {
return (
!workspace.isReadOnly() &&
!Gesture.inProgress() &&
!workspace.isDragging() &&
!getFocusManager().ephemeralFocusTaken()
);
},
@@ -390,12 +353,12 @@ export function registerUndo() {
*/
export function registerRedo() {
const ctrlShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
KeyCodes.SHIFT,
KeyCodes.CTRL,
KeyCodes.SHIFT,
]);
const metaShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
KeyCodes.SHIFT,
KeyCodes.META,
KeyCodes.SHIFT,
]);
// Ctrl-y is redo in Windows. Command-y is never valid on Macs.
const ctrlY = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Y, [
@@ -406,7 +369,7 @@ export function registerRedo() {
name: names.REDO,
preconditionFn(workspace) {
return (
!Gesture.inProgress() &&
!workspace.isDragging() &&
!workspace.isReadOnly() &&
!getFocusManager().ephemeralFocusTaken()
);

View File

@@ -43,6 +43,7 @@ import type {KeyboardShortcut} from '../shortcut_registry.js';
import * as Touch from '../touch.js';
import * as aria from '../utils/aria.js';
import * as dom from '../utils/dom.js';
import * as idGenerator from '../utils/idgenerator.js';
import {Rect} from '../utils/rect.js';
import * as toolbox from '../utils/toolbox.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
@@ -185,6 +186,7 @@ export class Toolbox
const svg = workspace.getParentSvg();
const container = this.createContainer_();
container.id = idGenerator.getNextUniqueId();
this.contentsDiv_ = this.createContentsContainer_();
aria.setRole(this.contentsDiv_, aria.Role.TREE);
@@ -1170,6 +1172,7 @@ Css.register(`
/* Category tree in Toolbox. */
.blocklyToolbox {
box-sizing: border-box;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;

View File

@@ -79,8 +79,8 @@ export class FocusableTreeTraverser {
* traversed but its nodes will never be returned here per the contract of
* IFocusableTree.lookUpFocusableNode.
*
* The provided element must have a non-null ID that conforms to the contract
* mentioned in IFocusableNode.
* The provided element must have a non-null, non-empty ID that conforms to
* the contract mentioned in IFocusableNode.
*
* @param element The HTML or SVG element being sought.
* @param tree The tree under which the provided element may be a descendant.
@@ -90,6 +90,10 @@ export class FocusableTreeTraverser {
element: HTMLElement | SVGElement,
tree: IFocusableTree,
): IFocusableNode | null {
// Note that the null check is due to Element.setAttribute() converting null
// to a string.
if (!element.id || element.id === 'null') return null;
// First, match against subtrees.
const subTreeMatches = tree.getNestedTrees().map((tree) => {
return FocusableTreeTraverser.findFocusableNodeFor(element, tree);

View File

@@ -32,6 +32,16 @@ export class Rect {
public right: number,
) {}
/**
* Converts a DOM or SVG Rect to a Blockly Rect.
*
* @param rect The rectangle to convert.
* @returns A representation of the same rectangle as a Blockly Rect.
*/
static from(rect: DOMRect | SVGRect): Rect {
return new Rect(rect.y, rect.y + rect.height, rect.x, rect.x + rect.width);
}
/**
* Creates a new copy of this rectangle.
*
@@ -51,6 +61,11 @@ export class Rect {
return this.right - this.left;
}
/** Returns the top left coordinate of this rectangle. */
getOrigin(): Coordinate {
return new Coordinate(this.left, this.top);
}
/**
* Tests whether this rectangle contains a x/y coordinate.
*

View File

@@ -21,6 +21,7 @@ import * as common from './common.js';
import type {ConnectionDB} from './connection_db.js';
import type {Abstract} from './events/events_abstract.js';
import * as eventUtils from './events/utils.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {IConnectionChecker} from './interfaces/i_connection_checker.js';
import {IProcedureMap} from './interfaces/i_procedure_map.js';
import type {IVariableMap} from './interfaces/i_variable_map.js';
@@ -35,6 +36,7 @@ import * as arrayUtils from './utils/array.js';
import * as deprecation from './utils/deprecation.js';
import * as idGenerator from './utils/idgenerator.js';
import * as math from './utils/math.js';
import {Rect} from './utils/rect.js';
import type * as toolbox from './utils/toolbox.js';
import {deleteVariable, getVariableUsesById} from './variables.js';
@@ -181,10 +183,31 @@ export class Workspace {
a: Block | WorkspaceComment,
b: Block | WorkspaceComment,
): number {
const wrap = (element: Block | WorkspaceComment) => {
return {
getBoundingRectangle: () => {
const xy = element.getRelativeToSurfaceXY();
return new Rect(xy.y, xy.y, xy.x, xy.x);
},
moveBy: () => {},
};
};
return this.sortByOrigin(wrap(a), wrap(b));
}
/**
* Sorts bounded elements on the workspace by their relative position, top to
* bottom (with slight LTR or RTL bias).
*
* @param a The first element to sort.
* @param b The second elment to sort.
* @returns -1, 0 or 1 depending on the sort order.
*/
protected sortByOrigin(a: IBoundedElement, b: IBoundedElement): number {
const offset =
Math.sin(math.toRadians(Workspace.SCAN_ANGLE)) * (this.RTL ? -1 : 1);
const aXY = a.getRelativeToSurfaceXY();
const bXY = b.getRelativeToSurfaceXY();
const aXY = a.getBoundingRectangle().getOrigin();
const bXY = b.getBoundingRectangle().getOrigin();
return aXY.y + offset * aXY.x - (bXY.y + offset * bXY.x);
}

View File

@@ -22,6 +22,9 @@ 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 {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';
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
import {WorkspaceComment} from './comments/workspace_comment.js';
import * as common from './common.js';
@@ -41,6 +44,7 @@ import type {FlyoutButton} from './flyout_button.js';
import {getFocusManager} from './focus_manager.js';
import {Gesture} from './gesture.js';
import {Grid} from './grid.js';
import {MutatorIcon} from './icons/mutator_icon.js';
import {isAutoHideable} from './interfaces/i_autohideable.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import {IContextMenu} from './interfaces/i_contextmenu.js';
@@ -476,10 +480,7 @@ export class WorkspaceSvg
* @internal
*/
getMarker(id: string): Marker | null {
if (this.markerManager) {
return this.markerManager.getMarker(id);
}
return null;
return this.markerManager.getMarker(id);
}
/**
@@ -487,11 +488,8 @@ export class WorkspaceSvg
*
* @returns The cursor for the workspace.
*/
getCursor(): LineCursor | null {
if (this.markerManager) {
return this.markerManager.getCursor();
}
return null;
getCursor(): LineCursor {
return this.markerManager.getCursor();
}
/**
@@ -895,10 +893,7 @@ export class WorkspaceSvg
}
this.renderer.dispose();
if (this.markerManager) {
this.markerManager.dispose();
}
this.markerManager.dispose();
super.dispose();
@@ -2264,8 +2259,8 @@ export class WorkspaceSvg
*
* @param comment comment to add.
*/
override addTopComment(comment: WorkspaceComment) {
this.addTopBoundedElement(comment as RenderedWorkspaceComment);
override addTopComment(comment: RenderedWorkspaceComment) {
this.addTopBoundedElement(comment);
super.addTopComment(comment);
}
@@ -2274,11 +2269,31 @@ export class WorkspaceSvg
*
* @param comment comment to remove.
*/
override removeTopComment(comment: WorkspaceComment) {
this.removeTopBoundedElement(comment as RenderedWorkspaceComment);
override removeTopComment(comment: RenderedWorkspaceComment) {
this.removeTopBoundedElement(comment);
super.removeTopComment(comment);
}
/**
* Returns a list of comments on this workspace.
*
* @param ordered If true, sorts the comments based on their position.
* @returns A list of workspace comments.
*/
override getTopComments(ordered = false): RenderedWorkspaceComment[] {
return super.getTopComments(ordered) as RenderedWorkspaceComment[];
}
/**
* Returns the workspace comment with the given ID, if any.
*
* @param id The ID of the comment to retrieve.
* @returns The workspace comment with the given ID, or null.
*/
override getCommentById(id: string): RenderedWorkspaceComment | null {
return super.getCommentById(id) as RenderedWorkspaceComment | null;
}
override getRootWorkspace(): WorkspaceSvg | null {
return super.getRootWorkspace() as WorkspaceSvg | null;
}
@@ -2306,8 +2321,15 @@ export class WorkspaceSvg
*
* @returns The top-level bounded elements.
*/
getTopBoundedElements(): IBoundedElement[] {
return new Array<IBoundedElement>().concat(this.topBoundedElements);
getTopBoundedElements(ordered = false): IBoundedElement[] {
const elements = new Array<IBoundedElement>().concat(
this.topBoundedElements,
);
if (ordered) {
elements.sort(this.sortByOrigin.bind(this));
}
return elements;
}
/**
@@ -2680,7 +2702,7 @@ export class WorkspaceSvg
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
return this;
return (this.isMutator && this.options.parentWorkspace) || this;
}
/** See IFocusableNode.onNodeFocus. */
@@ -2710,7 +2732,42 @@ export class WorkspaceSvg
/** See IFocusableTree.getNestedTrees. */
getNestedTrees(): Array<IFocusableTree> {
return [];
const nestedWorkspaces = this.getAllBlocks()
.map((block) => block.getIcons())
.flat()
.filter(
(icon): icon is MutatorIcon =>
icon instanceof MutatorIcon && icon.bubbleIsVisible(),
)
.map((icon) => icon.getBubble()?.getWorkspace())
.filter((workspace) => !!workspace);
const ownFlyout = this.getFlyout(true);
if (ownFlyout) {
nestedWorkspaces.push(ownFlyout.getWorkspace());
}
return nestedWorkspaces;
}
/**
* Used for searching for a specific workspace comment.
* We can't use this.getWorkspaceCommentById because the workspace
* comment ids might not be globally unique, but the id assigned to
* the focusable element for the comment should be.
*/
private searchForWorkspaceComment(
id: string,
): RenderedWorkspaceComment | undefined {
for (const comment of this.getTopComments()) {
if (
comment instanceof RenderedWorkspaceComment &&
comment.canBeFocused() &&
comment.getFocusableElement().id === id
) {
return comment;
}
}
}
/** See IFocusableTree.lookUpFocusableNode. */
@@ -2757,21 +2814,42 @@ export class WorkspaceSvg
return null;
}
// Search for a specific workspace comment or comment icon if the ID
// indicates the presence of one.
const commentIdSeparatorIndex = Math.max(
id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER),
id.indexOf(COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER),
id.indexOf(COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER),
);
if (commentIdSeparatorIndex !== -1) {
const commentId = id.substring(0, commentIdSeparatorIndex);
const comment = this.searchForWorkspaceComment(commentId);
if (comment) {
if (id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER) > -1) {
return comment.getEditorFocusableNode();
} else {
return (
comment.view
.getCommentBarButtons()
.find((button) => button.getFocusableElement().id.includes(id)) ??
null
);
}
}
}
// Search for a specific block.
// Don't use `getBlockById` because the block ID is not guaranteed
// to be globally unique, but the ID on the focusable element is.
const block = this.getAllBlocks(false).find(
(block) => block.getFocusableElement().id === id,
);
if (block) return block;
// Search for a workspace comment (semi-expensive).
for (const comment of this.getTopComments()) {
if (
comment instanceof RenderedWorkspaceComment &&
comment.canBeFocused() &&
comment.getFocusableElement().id === id
) {
return comment;
}
const comment = this.searchForWorkspaceComment(id);
if (comment) {
return comment;
}
// Search for icons and bubbles (which requires an expensive getAllBlocks).

View File

@@ -184,7 +184,7 @@ export default [
files: [
'eslint.config.mjs',
'.prettierrc.js',
'gulpfile.js',
'gulpfile.mjs',
'scripts/helpers.js',
'tests/mocha/.mocharc.js',
'tests/migration/validate-renamings.mjs',

View File

@@ -1,54 +0,0 @@
/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Gulp script to build Blockly for Node & NPM.
* Run this script by calling "npm install" in this directory.
*/
/* eslint-env node */
const gulp = require('gulp');
const buildTasks = require('./scripts/gulpfiles/build_tasks');
const packageTasks = require('./scripts/gulpfiles/package_tasks');
const gitTasks = require('./scripts/gulpfiles/git_tasks');
const appengineTasks = require('./scripts/gulpfiles/appengine_tasks');
const releaseTasks = require('./scripts/gulpfiles/release_tasks');
const docsTasks = require('./scripts/gulpfiles/docs_tasks');
const testTasks = require('./scripts/gulpfiles/test_tasks');
module.exports = {
// Default target if gulp invoked without specifying.
default: buildTasks.build,
// Main sequence targets. They already invoke prerequisites.
langfiles: buildTasks.langfiles, // Build build/msg/*.js from msg/json/*.
tsc: buildTasks.tsc,
deps: buildTasks.deps,
minify: buildTasks.minify,
build: buildTasks.build,
package: packageTasks.package,
publish: releaseTasks.publish,
publishBeta: releaseTasks.publishBeta,
prepareDemos: appengineTasks.prepareDemos,
deployDemos: appengineTasks.deployDemos,
deployDemosBeta: appengineTasks.deployDemosBeta,
gitUpdateGithubPages: gitTasks.updateGithubPages,
// Manually-invokable targets, with prerequisites where required.
messages: buildTasks.messages, // Generate msg/json/en.json et al.
clean: gulp.parallel(buildTasks.cleanBuildDir, packageTasks.cleanReleaseDir),
test: testTasks.test,
testGenerators: testTasks.generators,
buildAdvancedCompilationTest: buildTasks.buildAdvancedCompilationTest,
gitCreateRC: gitTasks.createRC,
docs: docsTasks.docs,
// Legacy targets, to be deleted.
recompile: releaseTasks.recompile,
gitSyncDevelop: gitTasks.syncDevelop,
gitSyncMaster: gitTasks.syncMaster,
};

95
gulpfile.mjs Normal file
View File

@@ -0,0 +1,95 @@
/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Gulp script to build Blockly for Node & NPM.
* Run this script by calling "npm install" in this directory.
*/
/* eslint-env node */
// Needed to prevent prettier from munging exports order, due to
// https://github.com/simonhaenisch/prettier-plugin-organize-imports/issues/146
// - but has the unfortunate side effect of suppressing ordering of
// imports too:
//
// organize-imports-ignore
import {parallel} from 'gulp';
import {
deployDemos,
deployDemosBeta,
prepareDemos,
} from './scripts/gulpfiles/appengine_tasks.mjs';
import {
build,
buildAdvancedCompilationTest,
cleanBuildDir,
langfiles,
messages,
minify,
tsc,
} from './scripts/gulpfiles/build_tasks.mjs';
import {docs} from './scripts/gulpfiles/docs_tasks.mjs';
import {
createRC,
syncDevelop,
syncMaster,
updateGithubPages,
} from './scripts/gulpfiles/git_tasks.mjs';
import {cleanReleaseDir, pack} from './scripts/gulpfiles/package_tasks.mjs';
import {
publish,
publishBeta,
recompile,
} from './scripts/gulpfiles/release_tasks.mjs';
import {generators, test} from './scripts/gulpfiles/test_tasks.mjs';
const clean = parallel(cleanBuildDir, cleanReleaseDir);
// Default target if gulp invoked without specifying.
export default build;
// Main sequence targets. They already invoke prerequisites. Listed
// in typical order of invocation, and strictly listing prerequisites
// before dependants.
//
// prettier-ignore
export {
langfiles,
tsc,
minify,
build,
pack, // Formerly package.
publishBeta,
publish,
prepareDemos,
deployDemosBeta,
deployDemos,
updateGithubPages as gitUpdateGithubPages,
}
// Manually-invokable targets that also invoke prerequisites where
// required.
//
// prettier-ignore
export {
messages, // Generate msg/json/en.json et al.
clean,
test,
generators as testGenerators,
buildAdvancedCompilationTest,
createRC as gitCreateRC,
docs,
}
// Legacy targets, to be deleted.
//
// prettier-ignore
export {
recompile,
syncDevelop as gitSyncDevelop,
syncMaster as gitSyncMaster,
}

View File

@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2025-04-21 10:42:10.549634",
"lastupdated": "2025-06-17 15:36:41.845826",
"locale": "en",
"messagedocumentation" : "qqq"
},
@@ -398,22 +398,8 @@
"COLLAPSED_WARNINGS_WARNING": "Collapsed blocks contain warnings.",
"DIALOG_OK": "OK",
"DIALOG_CANCEL": "Cancel",
"DELETE_SHORTCUT": "Delete block (%1)",
"DELETE_KEY": "Del",
"EDIT_BLOCK_CONTENTS": "Edit Block contents (%1)",
"INSERT_BLOCK": "Insert Block (%1)",
"START_MOVE": "Start move",
"FINISH_MOVE": "Finish move",
"ABORT_MOVE": "Abort move",
"MOVE_LEFT_CONSTRAINED": "Move left, constrained",
"MOVE_RIGHT_CONSTRAINED": "Move right constrained",
"MOVE_UP_CONSTRAINED": "Move up, constrained",
"MOVE_DOWN_CONSTRAINED": "Move down constrained",
"MOVE_LEFT_UNCONSTRAINED": "Move left, unconstrained",
"MOVE_RIGHT_UNCONSTRAINED": "Move right, unconstrained",
"MOVE_UP_UNCONSTRAINED": "Move up unconstrained",
"MOVE_DOWN_UNCONSTRAINED": "Move down, unconstrained",
"MOVE_BLOCK": "Move Block (%1)",
"EDIT_BLOCK_CONTENTS": "Edit Block contents",
"MOVE_BLOCK": "Move Block",
"WINDOWS": "Windows",
"MAC_OS": "macOS",
"CHROME_OS": "ChromeOS",
@@ -423,11 +409,15 @@
"COMMAND_KEY": "⌘ Command",
"OPTION_KEY": "⌥ Option",
"ALT_KEY": "Alt",
"CUT_SHORTCUT": "Cut (%1)",
"COPY_SHORTCUT": "Copy (%1)",
"PASTE_SHORTCUT": "Paste (%1)",
"CUT_SHORTCUT": "Cut",
"COPY_SHORTCUT": "Copy",
"PASTE_SHORTCUT": "Paste",
"HELP_PROMPT": "Press %1 for help on keyboard controls",
"SHORTCUTS_GENERAL": "General",
"SHORTCUTS_EDITING": "Editing",
"SHORTCUTS_CODE_NAVIGATION": "Code navigation"
"SHORTCUTS_CODE_NAVIGATION": "Code navigation",
"KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Hold %1 and use arrow keys to move freely, then %2 to accept the position",
"KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position",
"KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.",
"KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste."
}

View File

@@ -405,21 +405,7 @@
"COLLAPSED_WARNINGS_WARNING": "warning - This appears if the user collapses a block, and blocks inside that block have warnings attached to them. It should inform the user that the block they collapsed contains blocks that have warnings.",
"DIALOG_OK": "button label - Pressing this button closes help information.\n{{Identical|OK}}",
"DIALOG_CANCEL": "button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}}",
"DELETE_SHORTCUT": "menu label - Contextual menu item that deletes the focused block.",
"DELETE_KEY": "menu label - Keyboard shortcut for the Delete key, shown at the end of a menu item that deletes the focused block.",
"EDIT_BLOCK_CONTENTS": "menu label - Contextual menu item that moves the keyboard navigation cursor into a subitem of the focused block.",
"INSERT_BLOCK": "menu label - Contextual menu item that prompts the user to choose a block to insert into the program at the focused location.",
"START_MOVE": "keyboard shortcut label - Contextual menu item that starts a keyboard-driven move of the focused block.",
"FINISH_MOVE": "keyboard shortcut label - Contextual menu item that ends a keyboard-driven move of the focused block.",
"ABORT_MOVE": "keyboard shortcut label - Contextual menu item that ends a keyboard-drive move of the focused block by returning it to its original location.",
"MOVE_LEFT_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location to the left.",
"MOVE_RIGHT_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location to the right.",
"MOVE_UP_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location above it.",
"MOVE_DOWN_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location below it.",
"MOVE_LEFT_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely to the left.",
"MOVE_RIGHT_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely to the right.",
"MOVE_UP_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely upwards.",
"MOVE_DOWN_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely downwards.",
"MOVE_BLOCK": "menu label - Contextual menu item that starts a keyboard-driven block move.",
"WINDOWS": "Name of the Microsoft Windows operating system displayed in a list of keyboard shortcuts.",
"MAC_OS": "Name of the Apple macOS operating system displayed in a list of keyboard shortcuts,",
@@ -436,5 +422,9 @@
"HELP_PROMPT": "Alert message shown to prompt users to review available keyboard shortcuts.",
"SHORTCUTS_GENERAL": "shortcut list section header - Label for general purpose keyboard shortcuts.",
"SHORTCUTS_EDITING": "shortcut list section header - Label for keyboard shortcuts related to editing a workspace.",
"SHORTCUTS_CODE_NAVIGATION": "shortcut list section header - Label for keyboard shortcuts related to moving around the workspace."
"SHORTCUTS_CODE_NAVIGATION": "shortcut list section header - Label for keyboard shortcuts related to moving around the workspace.",
"KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks to arbitrary locations with the keyboard.",
"KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.",
"KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.",
"KEYBOARD_NAV_CUT_HINT": "Message shown when an item is cut in keyboard navigation mode."
}

View File

@@ -1618,68 +1618,13 @@ Blockly.Msg.DIALOG_OK = 'OK';
/// button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}}
Blockly.Msg.DIALOG_CANCEL = 'Cancel';
/** @type {string} */
/// menu label - Contextual menu item that deletes the focused block.
Blockly.Msg.DELETE_SHORTCUT = 'Delete block (%1)';
/** @type {string} */
/// menu label - Keyboard shortcut for the Delete key, shown at the end of a
/// menu item that deletes the focused block.
Blockly.Msg.DELETE_KEY = 'Del';
/** @type {string} */
/// menu label - Contextual menu item that moves the keyboard navigation cursor
/// into a subitem of the focused block.
Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents (%1)';
/** @type {string} */
/// menu label - Contextual menu item that prompts the user to choose a block to
/// insert into the program at the focused location.
Blockly.Msg.INSERT_BLOCK = 'Insert Block (%1)';
/** @type {string} */
/// keyboard shortcut label - Contextual menu item that starts a keyboard-driven
/// move of the focused block.
Blockly.Msg.START_MOVE = 'Start move';
/** @type {string} */
/// keyboard shortcut label - Contextual menu item that ends a keyboard-driven
/// move of the focused block.
Blockly.Msg.FINISH_MOVE = 'Finish move';
/** @type {string} */
/// keyboard shortcut label - Contextual menu item that ends a keyboard-drive
/// move of the focused block by returning it to its original location.
Blockly.Msg.ABORT_MOVE = 'Abort move';
/** @type {string} */
/// keyboard shortcut label - Description of shortcut that moves a block to the
/// next valid location to the left.
Blockly.Msg.MOVE_LEFT_CONSTRAINED = 'Move left, constrained';
/** @type {string} */
/// keyboard shortcut label - Description of shortcut that moves a block to the
/// next valid location to the right.
Blockly.Msg.MOVE_RIGHT_CONSTRAINED = 'Move right constrained';
/** @type {string} */
/// keyboard shortcut label - Description of shortcut that moves a block to the
/// next valid location above it.
Blockly.Msg.MOVE_UP_CONSTRAINED = 'Move up, constrained';
/** @type {string} */
/// keyboard shortcut label - Description of shortcut that moves a block to the
/// next valid location below it.
Blockly.Msg.MOVE_DOWN_CONSTRAINED = 'Move down constrained';
/** @type {string} */
/// keyboard shortcut label - Description of shortcut that moves a block freely
/// to the left.
Blockly.Msg.MOVE_LEFT_UNCONSTRAINED = 'Move left, unconstrained';
/** @type {string} */
/// keyboard shortcut label - Description of shortcut that moves a block freely
/// to the right.
Blockly.Msg.MOVE_RIGHT_UNCONSTRAINED = 'Move right, unconstrained';
/** @type {string} */
/// keyboard shortcut label - Description of shortcut that moves a block freely
/// upwards.
Blockly.Msg.MOVE_UP_UNCONSTRAINED = 'Move up unconstrained';
/** @type {string} */
/// keyboard shortcut label - Description of shortcut that moves a block freely
/// downwards.
Blockly.Msg.MOVE_DOWN_UNCONSTRAINED = 'Move down, unconstrained';
Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents';
/** @type {string} */
/// menu label - Contextual menu item that starts a keyboard-driven block move.
Blockly.Msg.MOVE_BLOCK = 'Move Block (%1)';
Blockly.Msg.MOVE_BLOCK = 'Move Block';
/** @type {string} */
/// Name of the Microsoft Windows operating system displayed in a list of
/// keyboard shortcuts.
@@ -1714,13 +1659,13 @@ Blockly.Msg.OPTION_KEY = '⌥ Option';
Blockly.Msg.ALT_KEY = 'Alt';
/** @type {string} */
/// menu label - Contextual menu item that cuts the focused item.
Blockly.Msg.CUT_SHORTCUT = 'Cut (%1)';
Blockly.Msg.CUT_SHORTCUT = 'Cut';
/** @type {string} */
/// menu label - Contextual menu item that copies the focused item.
Blockly.Msg.COPY_SHORTCUT = 'Copy (%1)';
Blockly.Msg.COPY_SHORTCUT = 'Copy';
/** @type {string} */
/// menu label - Contextual menu item that pastes the previously copied item.
Blockly.Msg.PASTE_SHORTCUT = 'Paste (%1)';
Blockly.Msg.PASTE_SHORTCUT = 'Paste';
/** @type {string} */
/// Alert message shown to prompt users to review available keyboard shortcuts.
Blockly.Msg.HELP_PROMPT = 'Press %1 for help on keyboard controls';
@@ -1735,3 +1680,16 @@ Blockly.Msg.SHORTCUTS_EDITING = 'Editing'
/// shortcut list section header - Label for keyboard shortcuts related to
/// moving around the workspace.
Blockly.Msg.SHORTCUTS_CODE_NAVIGATION = 'Code navigation';
/** @type {string} */
/// Message shown to inform users how to move blocks to arbitrary locations
/// with the keyboard.
Blockly.Msg.KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT = 'Hold %1 and use arrow keys to move freely, then %2 to accept the position';
/** @type {string} */
/// Message shown to inform users how to move blocks with the keyboard.
Blockly.Msg.KEYBOARD_NAV_CONSTRAINED_MOVE_HINT = 'Use the arrow keys to move, then %1 to accept the position';
/** @type {string} */
/// Message shown when an item is copied in keyboard navigation mode.
Blockly.Msg.KEYBOARD_NAV_COPIED_HINT = 'Copied. Press %1 to paste.';
/** @type {string} */
/// Message shown when an item is cut in keyboard navigation mode.
Blockly.Msg.KEYBOARD_NAV_CUT_HINT = 'Cut. Press %1 to paste.';

1608
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "blockly",
"version": "12.1.0",
"version": "12.2.0",
"description": "Blockly is a library for building visual programming editors.",
"keywords": [
"blockly"
@@ -33,7 +33,7 @@
"lint:fix": "eslint . --fix",
"langfiles": "gulp langfiles",
"minify": "gulp minify",
"package": "gulp package",
"package": "gulp pack",
"postinstall": "patch-package",
"prepareDemos": "gulp prepareDemos",
"publish": "npm ci && gulp publish",
@@ -100,7 +100,7 @@
},
"license": "Apache-2.0",
"devDependencies": {
"@blockly/block-test": "^6.0.4",
"@blockly/block-test": "^7.0.1",
"@blockly/dev-tools": "^9.0.0",
"@blockly/theme-modern": "^6.0.3",
"@hyperjump/browser": "^1.1.4",
@@ -113,11 +113,11 @@
"eslint": "^9.15.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-jsdoc": "^50.5.0",
"eslint-plugin-jsdoc": "^51.3.1",
"eslint-plugin-prettier": "^5.2.1",
"glob": "^11.0.1",
"globals": "^16.0.0",
"google-closure-compiler": "^20240317.0.0",
"google-closure-compiler": "^20250625.0.0",
"gulp": "^5.0.0",
"gulp-concat": "^2.6.1",
"gulp-gzip": "^1.4.2",

View File

@@ -8,16 +8,16 @@
* @fileoverview Gulp script to deploy Blockly demos on appengine.
*/
const gulp = require('gulp');
import * as gulp from 'gulp';
const fs = require('fs');
const path = require('path');
const execSync = require('child_process').execSync;
const buildTasks = require('./build_tasks.js');
const packageTasks = require('./package_tasks.js');
const {rimraf} = require('rimraf');
import * as fs from 'fs';
import * as path from 'path';
import {execSync} from 'child_process';
import * as buildTasks from './build_tasks.mjs';
import {getPackageJson} from './helper_tasks.mjs';
import * as packageTasks from './package_tasks.mjs';
import {rimraf} from 'rimraf';
const packageJson = require('../../package.json');
const demoTmpDir = '../_deploy';
const demoStaticTmpDir = '../_deploy/static';
@@ -123,7 +123,7 @@ function deployToAndClean(demoVersion) {
*/
function getDemosVersion() {
// Replace all '.' with '-' e.g. 9-3-3-beta-2
return packageJson.version.replace(/\./g, '-');
return getPackageJson().version.replace(/\./g, '-');
}
/**
@@ -162,7 +162,7 @@ function deployBetaAndClean(done) {
*
* Prerequisites (invoked): clean, build
*/
const prepareDemos = gulp.series(
export const prepareDemos = gulp.series(
prepareDeployDir,
gulp.parallel(
gulp.series(
@@ -180,16 +180,9 @@ const prepareDemos = gulp.series(
/**
* Deploys demos.
*/
const deployDemos = gulp.series(prepareDemos, deployAndClean);
export const deployDemos = gulp.series(prepareDemos, deployAndClean);
/**
* Deploys beta version of demos (version appended with -beta).
*/
const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean);
module.exports = {
// Main sequence targets. Each should invoke any immediate prerequisite(s).
deployDemos: deployDemos,
deployDemosBeta: deployDemosBeta,
prepareDemos: prepareDemos
};
export const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean);

View File

@@ -8,25 +8,28 @@
* @fileoverview Gulp script to build Blockly for Node & NPM.
*/
const gulp = require('gulp');
gulp.replace = require('gulp-replace');
gulp.rename = require('gulp-rename');
gulp.sourcemaps = require('gulp-sourcemaps');
import * as gulp from 'gulp';
import replace from 'gulp-replace';
import rename from 'gulp-rename';
import sourcemaps from 'gulp-sourcemaps';
const path = require('path');
const fs = require('fs');
const fsPromises = require('fs/promises');
const {exec, execSync} = require('child_process');
import * as path from 'path';
import * as fs from 'fs';
import * as fsPromises from 'fs/promises';
import {exec, execSync} from 'child_process';
const {globSync} = require('glob');
const closureCompiler = require('google-closure-compiler').gulp();
const argv = require('yargs').argv;
const {rimraf} = require('rimraf');
import {globSync} from 'glob';
import {gulp as closureCompiler} from 'google-closure-compiler';
import yargs from 'yargs';
import {hideBin} from 'yargs/helpers';
import {rimraf} from 'rimraf';
const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} = require('./config');
const {getPackageJson} = require('./helper_tasks');
import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} from './config.mjs';
import {getPackageJson} from './helper_tasks.mjs';
const {posixPath, quote} = require('../helpers');
import {posixPath, quote} from '../helpers.js';
const argv = yargs(hideBin(process.argv)).parse();
////////////////////////////////////////////////////////////
// Build //
@@ -182,7 +185,7 @@ function stripApacheLicense() {
// Closure Compiler preserves dozens of Apache licences in the Blockly code.
// Remove these if they belong to Google or MIT.
// MIT's permission to do this is logged in Blockly issue #2412.
return gulp.replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n');
return replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n');
// Replace with the same number of lines so that source-maps are not affected.
}
@@ -240,7 +243,6 @@ const JSCOMP_ERROR = [
'underscore',
'unknownDefines',
// 'unusedLocalVariables', // Disabled; see note in JSCOMP_OFF.
'unusedPrivateMembers',
'uselessCode',
'untranspilableFeatures',
// 'visibility', // Disabled; see note in JSCOMP_OFF.
@@ -306,7 +308,7 @@ const JSCOMP_OFF = [
* Builds Blockly as a JS program, by running tsc on all the files in
* the core directory.
*/
function buildJavaScript(done) {
export function tsc(done) {
execSync(
`tsc -outDir "${TSC_OUTPUT_DIR}" -declarationDir "${TYPINGS_BUILD_DIR}"`,
{stdio: 'inherit'});
@@ -318,7 +320,7 @@ function buildJavaScript(done) {
* This task regenerates msg/json/en.js and msg/json/qqq.js from
* msg/messages.js.
*/
function generateMessages(done) {
export function messages(done) {
// Run js_to_json.py
const jsToJsonCmd = `${PYTHON} scripts/i18n/js_to_json.py \
--input_file ${path.join('msg', 'messages.js')} \
@@ -573,10 +575,10 @@ function buildCompiled() {
// Fire up compilation pipline.
return gulp.src(chunkOptions.js, {base: './'})
.pipe(stripApacheLicense())
.pipe(gulp.sourcemaps.init())
.pipe(sourcemaps.init())
.pipe(compile(options))
.pipe(gulp.rename({suffix: COMPILED_SUFFIX}))
.pipe(gulp.sourcemaps.write('.'))
.pipe(rename({suffix: COMPILED_SUFFIX}))
.pipe(sourcemaps.write('.'))
.pipe(gulp.dest(RELEASE_DIR));
}
@@ -668,7 +670,7 @@ async function buildLangfileShims() {
// (We have to do it this way because messages.js is a script and
// not a CJS module with exports.)
globalThis.Blockly = {Msg: {}};
require('../../msg/messages.js');
await import('../../msg/messages.js');
const exportedNames = Object.keys(globalThis.Blockly.Msg);
delete globalThis.Blockly;
@@ -689,12 +691,14 @@ ${exportedNames.map((name) => ` ${name},`).join('\n')}
}
/**
* This task builds Blockly core, blocks and generators together and uses
* Closure Compiler's ADVANCED_COMPILATION mode.
* This task uses Closure Compiler's ADVANCED_COMPILATION mode to
* compile together Blockly core, blocks and generators with a simple
* test app; the purpose is to verify that Blockly is compatible with
* the ADVANCED_COMPILATION mode.
*
* Prerequisite: buildJavaScript.
*/
function buildAdvancedCompilationTest() {
function compileAdvancedCompilationTest() {
// If main_compressed.js exists (from a previous run) delete it so that
// a later browser-based test won't check it should the compile fail.
try {
@@ -718,9 +722,9 @@ function buildAdvancedCompilationTest() {
};
return gulp.src(srcs, {base: './'})
.pipe(stripApacheLicense())
.pipe(gulp.sourcemaps.init())
.pipe(sourcemaps.init())
.pipe(compile(options))
.pipe(gulp.sourcemaps.write(
.pipe(sourcemaps.write(
'.', {includeContent: false, sourceRoot: '../../'}))
.pipe(gulp.dest('./tests/compile/'));
}
@@ -728,7 +732,7 @@ function buildAdvancedCompilationTest() {
/**
* This task cleans the build directory (by deleting it).
*/
function cleanBuildDir() {
export function cleanBuildDir() {
// Sanity check.
if (BUILD_DIR === '.' || BUILD_DIR === '/') {
return Promise.reject(`Refusing to rm -rf ${BUILD_DIR}`);
@@ -737,16 +741,13 @@ function cleanBuildDir() {
}
// Main sequence targets. Each should invoke any immediate prerequisite(s).
exports.cleanBuildDir = cleanBuildDir;
exports.langfiles = gulp.parallel(buildLangfiles, buildLangfileShims);
exports.tsc = buildJavaScript;
exports.minify = gulp.series(exports.tsc, buildCompiled, buildShims);
exports.build = gulp.parallel(exports.minify, exports.langfiles);
// function cleanBuildDir, above
export const langfiles = gulp.parallel(buildLangfiles, buildLangfileShims);
export const minify = gulp.series(tsc, buildCompiled, buildShims);
// function tsc, above
export const build = gulp.parallel(minify, langfiles);
// Manually-invokable targets, with prerequisites where required.
exports.messages = generateMessages; // Generate msg/json/en.json et al.
exports.buildAdvancedCompilationTest =
gulp.series(exports.tsc, buildAdvancedCompilationTest);
// Targets intended only for invocation by scripts; may omit prerequisites.
exports.onlyBuildAdvancedCompilationTest = buildAdvancedCompilationTest;
// function messages, above
export const buildAdvancedCompilationTest =
gulp.series(tsc, compileAdvancedCompilationTest);

View File

@@ -8,7 +8,7 @@
* @fileoverview Common configuration for Gulp scripts.
*/
const path = require('path');
import * as path from 'path';
// Paths are all relative to the repository root. Do not include
// trailing slash.
@@ -21,21 +21,21 @@ const path = require('path');
// - tests/scripts/update_metadata.sh
// Directory to write compiled output to.
exports.BUILD_DIR = 'build';
export const BUILD_DIR = 'build';
// Directory to write typings output to.
exports.TYPINGS_BUILD_DIR = path.join(exports.BUILD_DIR, 'declarations');
export const TYPINGS_BUILD_DIR = path.join(BUILD_DIR, 'declarations');
// Directory to write langfile output to.
exports.LANG_BUILD_DIR = path.join(exports.BUILD_DIR, 'msg');
export const LANG_BUILD_DIR = path.join(BUILD_DIR, 'msg');
// Directory where typescript compiler output can be found.
// Matches the value in tsconfig.json: outDir
exports.TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'src');
export const TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'src');
// Directory for files generated by compiling test code.
exports.TEST_TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'tests');
export const TEST_TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'tests');
// Directory in which to assemble (and from which to publish) the
// blockly npm package.
exports.RELEASE_DIR = 'dist';
export const RELEASE_DIR = 'dist';

View File

@@ -1,9 +1,9 @@
const {execSync} = require('child_process');
const {Extractor} = require('markdown-tables-to-json');
const fs = require('fs');
const gulp = require('gulp');
const header = require('gulp-header');
const replace = require('gulp-replace');
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';
const DOCS_DIR = 'docs';
@@ -140,8 +140,7 @@ const createToc = function(done) {
done();
}
const docs = gulp.series(
export const docs = gulp.series(
generateApiJson, removeRenames, generateDocs,
gulp.parallel(prependBook, createToc));
module.exports = {docs};

View File

@@ -8,11 +8,11 @@
* @fileoverview Git-related gulp tasks for Blockly.
*/
const gulp = require('gulp');
const execSync = require('child_process').execSync;
import * as gulp from 'gulp';
import {execSync} from 'child_process';
const buildTasks = require('./build_tasks');
const packageTasks = require('./package_tasks');
import * as buildTasks from './build_tasks.mjs';
import * as packageTasks from './package_tasks.mjs';
const UPSTREAM_URL = 'https://github.com/google/blockly.git';
@@ -63,7 +63,7 @@ function syncBranch(branchName) {
* Stash current state, check out develop, and sync with
* google/blockly.
*/
function syncDevelop() {
export function syncDevelop() {
return syncBranch('develop');
};
@@ -71,7 +71,7 @@ function syncDevelop() {
* Stash current state, check out master, and sync with
* google/blockly.
*/
function syncMaster() {
export function syncMaster() {
return syncBranch('master');
};
@@ -111,7 +111,7 @@ function checkoutBranch(branchName) {
* Create and push an RC branch.
* Note that this pushes to google/blockly.
*/
const createRC = gulp.series(
export const createRC = gulp.series(
syncDevelop(),
function(done) {
const branchName = getRCBranchName();
@@ -122,7 +122,7 @@ const createRC = gulp.series(
);
/** Create the rebuild branch. */
function createRebuildBranch(done) {
export function createRebuildBranch(done) {
const branchName = getRebuildBranchName();
console.log(`make-rebuild-branch: creating branch ${branchName}`);
execSync(`git switch -C ${branchName}`, { stdio: 'inherit' });
@@ -130,7 +130,7 @@ function createRebuildBranch(done) {
}
/** Push the rebuild branch to origin. */
function pushRebuildBranch(done) {
export function pushRebuildBranch(done) {
console.log('push-rebuild-branch: committing rebuild');
execSync('git commit -am "Rebuild"', { stdio: 'inherit' });
const branchName = getRebuildBranchName();
@@ -145,7 +145,7 @@ function pushRebuildBranch(done) {
*
* Prerequisites (invoked): clean, build.
*/
const updateGithubPages = gulp.series(
export const updateGithubPages = gulp.series(
function(done) {
execSync('git stash save -m "Stash for sync"', { stdio: 'inherit' });
execSync('git switch -C gh-pages', { stdio: 'inherit' });
@@ -165,17 +165,3 @@ const updateGithubPages = gulp.series(
done();
}
);
module.exports = {
// Main sequence targets. Each should invoke any immediate prerequisite(s).
updateGithubPages,
// Manually-invokable targets that invoke prerequisites.
createRC,
// Legacy script-only targets, to be deleted.
syncDevelop,
syncMaster,
createRebuildBranch,
pushRebuildBranch,
};

View File

@@ -1,19 +0,0 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Any gulp helper functions.
*/
// Clears the require cache to ensure the package.json is up to date.
function getPackageJson() {
delete require.cache[require.resolve('../../package.json')]
return require('../../package.json');
}
module.exports = {
getPackageJson: getPackageJson
}

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Any gulp helper functions.
*/
import Module from "node:module";
const require = Module.createRequire(import.meta.url);
/**
* Load and return the contents of package.json.
*
* Uses require() rather than import, and clears the require cache, to
* ensure the loaded package.json data is up to date.
*/
export function getPackageJson() {
delete require.cache[require.resolve('../../package.json')];
return require('../../package.json');
}

View File

@@ -8,20 +8,17 @@
* @fileoverview Gulp tasks to package Blockly for distribution on NPM.
*/
const gulp = require('gulp');
gulp.concat = require('gulp-concat');
gulp.replace = require('gulp-replace');
gulp.rename = require('gulp-rename');
gulp.insert = require('gulp-insert');
gulp.umd = require('gulp-umd');
gulp.replace = require('gulp-replace');
import * as gulp from 'gulp';
import concat from 'gulp-concat';
import replace from 'gulp-replace';
import umd from 'gulp-umd';
const path = require('path');
const fs = require('fs');
const {rimraf} = require('rimraf');
const build = require('./build_tasks');
const {getPackageJson} = require('./helper_tasks');
const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} = require('./config');
import * as path from 'path';
import * as fs from 'fs';
import {rimraf} from 'rimraf';
import * as build from './build_tasks.mjs';
import {getPackageJson} from './helper_tasks.mjs';
import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} from './config.mjs';
// Path to template files for gulp-umd.
const TEMPLATE_DIR = 'scripts/package/templates';
@@ -32,7 +29,7 @@ const TEMPLATE_DIR = 'scripts/package/templates';
* @param {Array<Object>} dependencies An array of dependencies to inject.
*/
function packageUMD(namespace, dependencies, template = 'umd.template') {
return gulp.umd({
return umd({
dependencies: function () { return dependencies; },
namespace: function () { return namespace; },
exports: function () { return namespace; },
@@ -88,7 +85,7 @@ function packageCoreNode() {
function packageLocales() {
// Remove references to goog.provide and goog.require.
return gulp.src(`${LANG_BUILD_DIR}/*.js`)
.pipe(gulp.replace(/goog\.[^\n]+/g, ''))
.pipe(replace(/goog\.[^\n]+/g, ''))
.pipe(packageUMD('Blockly.Msg', [], 'umd-msg.template'))
.pipe(gulp.dest(`${RELEASE_DIR}/msg`));
};
@@ -107,7 +104,7 @@ function packageUMDBundle() {
`${RELEASE_DIR}/javascript_compressed.js`,
];
return gulp.src(srcs)
.pipe(gulp.concat('blockly.min.js'))
.pipe(concat('blockly.min.js'))
.pipe(gulp.dest(`${RELEASE_DIR}`));
};
@@ -140,7 +137,7 @@ function packageUMDBundle() {
* @param {Function} done Callback to call when done.
*/
function packageLegacyEntrypoints(done) {
for (entrypoint of [
for (const entrypoint of [
'core', 'blocks', 'dart', 'javascript', 'lua', 'php', 'python'
]) {
const bundle =
@@ -218,14 +215,14 @@ function packageDTS() {
.pipe(gulp.src(`${TYPINGS_BUILD_DIR}/**/*.d.ts`, {ignore: [
`${TYPINGS_BUILD_DIR}/blocks/**/*`,
]}))
.pipe(gulp.replace('AnyDuringMigration', 'any'))
.pipe(replace('AnyDuringMigration', 'any'))
.pipe(gulp.dest(RELEASE_DIR));
};
/**
* This task cleans the release directory (by deleting it).
*/
function cleanReleaseDir() {
export function cleanReleaseDir() {
// Sanity check.
if (RELEASE_DIR === '.' || RELEASE_DIR === '/') {
return Promise.reject(`Refusing to rm -rf ${RELEASE_DIR}`);
@@ -237,9 +234,13 @@ function cleanReleaseDir() {
* This task prepares the files to be included in the NPM by copying
* them into the release directory.
*
* This task was formerly called "package" but was renamed in
* preparation for porting gulpfiles to ESM because "package" is a
* reserved word.
*
* Prerequisite: build.
*/
const package = gulp.series(
export const pack = gulp.series(
gulp.parallel(
build.cleanBuildDir,
cleanReleaseDir),
@@ -254,9 +255,3 @@ const package = gulp.series(
packageReadme,
packageDTS)
);
module.exports = {
// Main sequence targets. Each should invoke any immediate prerequisite(s).
cleanReleaseDir: cleanReleaseDir,
package: package,
};

View File

@@ -8,15 +8,15 @@
* @fileoverview Gulp scripts for releasing Blockly.
*/
const execSync = require('child_process').execSync;
const fs = require('fs');
const gulp = require('gulp');
const readlineSync = require('readline-sync');
import {execSync} from 'child_process';
import * as fs from 'fs';
import * as gulp from 'gulp';
import * as readlineSync from 'readline-sync';
const gitTasks = require('./git_tasks');
const packageTasks = require('./package_tasks');
const {getPackageJson} = require('./helper_tasks');
const {RELEASE_DIR} = require('./config');
import * as gitTasks from './git_tasks.mjs';
import * as packageTasks from './package_tasks.mjs';
import {getPackageJson} from './helper_tasks.mjs';
import {RELEASE_DIR} from './config.mjs';
// Gets the current major version.
@@ -147,17 +147,17 @@ function updateBetaVersion(done) {
}
// Rebuild, package and publish to npm.
const publish = gulp.series(
packageTasks.package, // Does clean + build.
export const publish = gulp.series(
packageTasks.pack, // Does clean + build.
checkBranch,
checkReleaseDir,
loginAndPublish
);
// Rebuild, package and publish a beta version of Blockly.
const publishBeta = gulp.series(
export const publishBeta = gulp.series(
updateBetaVersion,
packageTasks.package, // Does clean + build.
packageTasks.pack, // Does clean + build.
checkBranch,
checkReleaseDir,
loginAndPublishBeta
@@ -165,19 +165,10 @@ const publishBeta = gulp.series(
// Switch to a new branch, update the version number, build Blockly
// and check in the resulting built files.
const recompileDevelop = gulp.series(
export const recompile = gulp.series(
gitTasks.syncDevelop(),
gitTasks.createRebuildBranch,
updateVersionPrompt,
packageTasks.package, // Does clean + build.
packageTasks.pack, // Does clean + build.
gitTasks.pushRebuildBranch
);
module.exports = {
// Main sequence targets. Each should invoke any immediate prerequisite(s).
publishBeta,
publish,
// Legacy target, to be deleted.
recompile: recompileDevelop,
};

View File

@@ -9,19 +9,19 @@
*/
/* eslint-env node */
const asyncDone = require('async-done');
const gulp = require('gulp');
const gzip = require('gulp-gzip');
const fs = require('fs');
const path = require('path');
const {execSync} = require('child_process');
const {rimraf} = require('rimraf');
import asyncDone from 'async-done';
import * as gulp from 'gulp';
import gzip from 'gulp-gzip';
import * as fs from 'fs';
import * as path from 'path';
import {execSync} from 'child_process';
import {rimraf} from 'rimraf';
const {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} = require('./config');
import {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} from './config.mjs';
const {runMochaTestsInBrowser} = require('../../tests/mocha/webdriver.js');
const {runGeneratorsInBrowser} = require('../../tests/generators/webdriver.js');
const {runCompileCheckInBrowser} = require('../../tests/compile/webdriver.js');
import {runMochaTestsInBrowser} from '../../tests/mocha/webdriver.js';
import {runGeneratorsInBrowser} from '../../tests/generators/webdriver.js';
import {runCompileCheckInBrowser} from '../../tests/compile/webdriver.js';
const OUTPUT_DIR = 'build/generators';
const GOLDEN_DIR = 'tests/generators/golden';
@@ -321,7 +321,7 @@ function checkResult(suffix) {
* Run generator tests inside a browser and check the results.
* @return {Promise} Asynchronous result.
*/
async function generators() {
export async function generators() {
return runTestTask('generators', async () => {
// Clean up.
rimraf.sync(OUTPUT_DIR);
@@ -396,10 +396,6 @@ const tasks = [
advancedCompileInBrowser
];
const test = gulp.series(...tasks, reportTestResult);
export const test = gulp.series(...tasks, reportTestResult);
module.exports = {
test,
generators,
};

View File

@@ -8,6 +8,7 @@ import * as chai from 'chai';
import {Key} from 'webdriverio';
import {
clickBlock,
clickWorkspace,
contextMenuSelect,
getAllBlocks,
getBlockElementById,
@@ -176,8 +177,7 @@ suite('Delete blocks', function (done) {
);
});
// TODO(#9029) enable this test once deleting a block doesn't lose focus
test.skip('Undo block deletion', async function () {
test('Undo block deletion', async function () {
const before = (await getAllBlocks(this.browser)).length;
// Get first print block, click to select it, and delete it using backspace key.
await clickBlock(this.browser, this.firstBlock.id, {button: 1});
@@ -204,6 +204,7 @@ suite('Delete blocks', function (done) {
await this.browser.keys([Key.Ctrl, 'z']);
await this.browser.pause(PAUSE_TIME);
// Redo
await clickWorkspace(this.browser);
await this.browser.keys([Key.Ctrl, Key.Shift, 'z']);
await this.browser.pause(PAUSE_TIME);
const after = (await getAllBlocks(this.browser)).length;

View File

@@ -40,8 +40,7 @@ suite('This tests loading Large Configuration and Deletion', function (done) {
chai.assert.equal(allBlocks.length, 10);
});
// TODO(#8793) Re-enable test after deleting a block updates focus correctly.
test.skip('undoing delete block results in the correct number of blocks', async function () {
test('undoing delete block results in the correct number of blocks', async function () {
await this.browser.keys([Key.Ctrl, 'z']);
await this.browser.pause(PAUSE_TIME);
const allBlocks = await getAllBlocks(this.browser);

View File

@@ -11,9 +11,8 @@
import * as chai from 'chai';
import {
connect,
getBlockTypeFromCategory,
getNthBlockOfCategory,
getSelectedBlockElement,
dragBlockTypeFromFlyout,
dragNthBlockFromFlyout,
PAUSE_TIME,
testFileLocations,
testSetup,
@@ -33,43 +32,41 @@ suite('Testing Connecting Blocks', function (done) {
test('Testing Procedure', async function () {
// Drag out first function
let proceduresDefReturn = await getBlockTypeFromCategory(
const doSomething = await dragBlockTypeFromFlyout(
this.browser,
'Functions',
'procedures_defreturn',
50,
20,
);
await proceduresDefReturn.dragAndDrop({x: 50, y: 20});
const doSomething = await getSelectedBlockElement(this.browser);
// Drag out second function.
proceduresDefReturn = await getBlockTypeFromCategory(
const doSomething2 = await dragBlockTypeFromFlyout(
this.browser,
'Functions',
'procedures_defreturn',
50,
20,
);
await proceduresDefReturn.dragAndDrop({x: 300, y: 200});
const doSomething2 = await getSelectedBlockElement(this.browser);
// Drag out numeric
const mathNumeric = await getBlockTypeFromCategory(
const numeric = await dragBlockTypeFromFlyout(
this.browser,
'Math',
'math_number',
50,
20,
);
await mathNumeric.dragAndDrop({x: 50, y: 20});
const numeric = await getSelectedBlockElement(this.browser);
// Connect numeric to first procedure
await connect(this.browser, numeric, 'OUTPUT', doSomething, 'RETURN');
// Drag out doSomething caller from flyout.
const doSomethingFlyout = await getNthBlockOfCategory(
const doSomethingCaller = await dragNthBlockFromFlyout(
this.browser,
'Functions',
3,
50,
20,
);
await doSomethingFlyout.dragAndDrop({x: 50, y: 20});
const doSomethingCaller = await getSelectedBlockElement(this.browser);
// Connect the doSomething caller to doSomething2
await connect(
@@ -81,22 +78,22 @@ suite('Testing Connecting Blocks', function (done) {
);
// Drag out print from flyout.
const printFlyout = await getBlockTypeFromCategory(
const print = await dragBlockTypeFromFlyout(
this.browser,
'Text',
'text_print',
50,
0,
);
await printFlyout.dragAndDrop({x: 50, y: 20});
const print = await getSelectedBlockElement(this.browser);
// Drag out doSomething2 caller from flyout.
const doSomething2Flyout = await getNthBlockOfCategory(
const doSomething2Caller = await dragNthBlockFromFlyout(
this.browser,
'Functions',
4,
50,
20,
);
await doSomething2Flyout.dragAndDrop({x: 130, y: 20});
const doSomething2Caller = await getSelectedBlockElement(this.browser);
// Connect doSomething2 caller with print.
await connect(this.browser, doSomething2Caller, 'OUTPUT', print, 'TEXT');

View File

@@ -62,6 +62,8 @@ export async function driverSetup() {
// Use Selenium to bring up the page
console.log('Starting webdriverio...');
driver = await webdriverio.remote(options);
driver.setWindowSize(800, 600);
driver.setViewport({width: 800, height: 600});
return driver;
}
@@ -170,43 +172,52 @@ export async function getBlockElementById(browser, id) {
* @return A Promise that resolves when the actions are completed.
*/
export async function clickBlock(browser, blockId, clickOptions) {
const findableId = 'clickTargetElement';
// In the browser context, find the element that we want and give it a findable ID.
await browser.execute(
(blockId, newElemId) => {
const block = Blockly.getMainWorkspace().getBlockById(blockId);
// Ensure the block we want to click is within the viewport.
Blockly.getMainWorkspace().scrollBoundsIntoView(
block.getBoundingRectangleWithoutChildren(),
10,
);
const elem = await getTargetableBlockElement(browser, blockId, false);
await elem.click(clickOptions);
}
/**
* Find an element on the block that is suitable for a click or drag.
*
* We can't always use the block's SVG root because clicking will always happen
* in the middle of the block's bounds (including children) by default, which
* causes problems if it has holes (e.g. statement inputs). Instead, this tries
* to get the first text field on the block. It falls back on the block's SVG root.
* @param browser The active WebdriverIO Browser object.
* @param blockId The id of the block to click, as an interactable element.
* @param toolbox True if this block is in the toolbox (which must be open already).
* @return A Promise that returns an appropriate element.
*/
async function getTargetableBlockElement(browser, blockId, toolbox) {
const id = await browser.execute(
(blockId, toolbox, newElemId) => {
const ws = toolbox
? Blockly.getMainWorkspace().getFlyout().getWorkspace()
: Blockly.getMainWorkspace();
const block = ws.getBlockById(blockId);
// Ensure the block we want to click/drag is within the viewport.
ws.scrollBoundsIntoView(block.getBoundingRectangleWithoutChildren(), 10);
if (!block.isCollapsed()) {
for (const input of block.inputList) {
for (const field of input.fieldRow) {
if (field instanceof Blockly.FieldLabel) {
field.getSvgRoot().id = newElemId;
return;
// Expose the id of the element we want to target
field.getSvgRoot().setAttribute('data-id', field.id_);
return field.getSvgRoot().id;
}
}
}
}
// No label field found. Fall back to the block's SVG root.
block.getSvgRoot().id = newElemId;
// No label field found. Fall back to the block's SVG root, which should
// already use the block id.
return block.id;
},
blockId,
findableId,
toolbox,
);
// In the test context, get the Webdriverio Element that we've identified.
const elem = await browser.$(`#${findableId}`);
await elem.click(clickOptions);
// In the browser context, remove the ID.
await browser.execute((elemId) => {
const clickElem = document.getElementById(elemId);
clickElem.removeAttribute('id');
}, findableId);
return await getBlockElementById(browser, id);
}
/**
@@ -215,7 +226,7 @@ export async function clickBlock(browser, blockId, clickOptions) {
* @return A Promise that resolves when the actions are completed.
*/
export async function clickWorkspace(browser) {
const workspace = await browser.$('#blocklyDiv > div > svg.blocklySvg > g');
const workspace = await browser.$('svg.blocklySvg > g');
await workspace.click();
await browser.pause(PAUSE_TIME);
}
@@ -253,27 +264,14 @@ export async function getCategory(browser, categoryName) {
}
/**
* @param browser The active WebdriverIO Browser object.
* @param categoryName The name of the toolbox category to search.
* @param n Which block to select, 0-indexed from the top of the category.
* @return A Promise that resolves to the root element of the nth
* block in the given category.
*/
export async function getNthBlockOfCategory(browser, categoryName, n) {
const category = await getCategory(browser, categoryName);
await category.click();
const block = (
await browser.$$(`.blocklyFlyout .blocklyBlockCanvas > .blocklyDraggable`)
)[n];
return block;
}
/**
* Opens the specified category, finds the first block of the given type,
* scrolls it into view, and returns a draggable element on that block.
*
* @param browser The active WebdriverIO Browser object.
* @param categoryName The name of the toolbox category to search.
* Null if the toolbox has no categories (simple).
* @param blockType The type of the block to search for.
* @return A Promise that resolves to the root element of the first
* @return A Promise that resolves to a draggable element of the first
* block with the given type in the given category.
*/
export async function getBlockTypeFromCategory(
@@ -286,13 +284,14 @@ export async function getBlockTypeFromCategory(
await category.click();
}
await browser.pause(PAUSE_TIME);
const id = await browser.execute((blockType) => {
return Blockly.getMainWorkspace()
.getFlyout()
.getWorkspace()
.getBlocksByType(blockType)[0].id;
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
const block = ws.getBlocksByType(blockType)[0];
ws.scrollBoundsIntoView(block.getBoundingRectangleWithoutChildren());
return block.id;
}, blockType);
return getBlockElementById(browser, id);
return getTargetableBlockElement(browser, id, true);
}
/**
@@ -447,7 +446,16 @@ export async function switchRTL(browser) {
* created block.
*/
export async function dragNthBlockFromFlyout(browser, categoryName, n, x, y) {
const flyoutBlock = await getNthBlockOfCategory(browser, categoryName, n);
const category = await getCategory(browser, categoryName);
await category.click();
await browser.pause(PAUSE_TIME);
const id = await browser.execute((n) => {
const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace();
const block = ws.getTopBlocks(true)[n];
return block.id;
}, n);
const flyoutBlock = await getTargetableBlockElement(browser, id, true);
await flyoutBlock.dragAndDrop({x: x, y: y});
return await getSelectedBlockElement(browser);
}
@@ -480,6 +488,7 @@ export async function dragBlockTypeFromFlyout(
type,
);
await flyoutBlock.dragAndDrop({x: x, y: y});
await browser.pause(PAUSE_TIME);
return await getSelectedBlockElement(browser);
}
@@ -584,26 +593,3 @@ export async function getAllBlocks(browser) {
}));
});
}
/**
* Find the flyout's scrollbar and scroll by the specified amount.
* This makes several assumptions:
* - A flyout with a valid scrollbar exists, is open, and is in view.
* - The workspace has a trash can, which means it has a second (hidden) flyout.
* @param browser The active WebdriverIO Browser object.
* @param xDelta How far to drag the flyout in the x direction. Positive is right.
* @param yDelta How far to drag the flyout in the y direction. Positive is down.
* @return A Promise that resolves when the actions are completed.
*/
export async function scrollFlyout(browser, xDelta, yDelta) {
// There are two flyouts on the playground workspace: one for the trash can
// and one for the toolbox. We want the second one.
// This assumes there is only one scrollbar handle in the flyout, but it could
// be either horizontal or vertical.
await browser.pause(PAUSE_TIME);
const scrollbarHandle = await browser
.$$(`.blocklyFlyoutScrollbar`)[1]
.$(`rect.blocklyScrollbarHandle`);
await scrollbarHandle.dragAndDrop({x: xDelta, y: yDelta});
await browser.pause(PAUSE_TIME);
}

View File

@@ -9,11 +9,12 @@
*/
import * as chai from 'chai';
import {Key} from 'webdriverio';
import {
dragBlockTypeFromFlyout,
getCategory,
PAUSE_TIME,
screenDirection,
scrollFlyout,
testFileLocations,
testSetup,
} from './test_setup.mjs';
@@ -57,28 +58,29 @@ const testCategories = [
];
/**
* Check whether an element is fully inside the bounds of the Blockly div. You can use this
* to determine whether a block on the workspace or flyout is inside the Blockly div.
* This does not check whether there are other Blockly elements (such as a toolbox or
* flyout) on top of the element. A partially visible block is considered out of bounds.
* Get the type of the nth block in the specified category.
* @param browser The active WebdriverIO Browser object.
* @param element The element to look for.
* @returns A Promise resolving to true if the element is in bounds and false otherwise.
* @param categoryName The name of the category to inspect.
* @param n The index of the block to get
* @returns A Promise resolving to the type the block in the specified
* category's flyout at index i.
*/
async function elementInBounds(browser, element) {
return await browser.execute((elem) => {
const rect = elem.getBoundingClientRect();
async function getNthBlockType(browser, categoryName, n) {
const category = await getCategory(browser, categoryName);
await category.click();
await browser.pause(PAUSE_TIME);
const blocklyDiv = document.getElementById('blocklyDiv');
const blocklyRect = blocklyDiv.getBoundingClientRect();
const blockType = await browser.execute((i) => {
return Blockly.getMainWorkspace()
.getFlyout()
.getWorkspace()
.getTopBlocks(false)[i].type;
}, n);
const vertInView =
rect.top >= blocklyRect.top && rect.bottom <= blocklyRect.bottom;
const horInView =
rect.left >= blocklyRect.left && rect.right <= blocklyRect.right;
return vertInView && horInView;
}, element);
// Unicode escape to close flyout.
await browser.keys([Key.Escape]);
await browser.pause(PAUSE_TIME);
return blockType;
}
/**
@@ -101,7 +103,7 @@ async function getBlockCount(browser, categoryName) {
});
// Unicode escape to close flyout.
await browser.keys(['\uE00C']);
await browser.keys([Key.Escape]);
await browser.pause(PAUSE_TIME);
return blockCount;
}
@@ -141,18 +143,12 @@ async function openCategories(browser, categoryList, directionMultiplier) {
await category.click();
if (await isBlockDisabled(browser, i)) {
// Unicode escape to close flyout.
await browser.keys(['\uE00C']);
await browser.keys([Key.Escape]);
await browser.pause(PAUSE_TIME);
continue;
}
const flyoutBlock = await browser.$(
`.blocklyFlyout .blocklyBlockCanvas > g:nth-child(${3 + i * 2})`,
);
while (!(await elementInBounds(browser, flyoutBlock))) {
await scrollFlyout(browser, 0, 50);
}
await flyoutBlock.dragAndDrop({x: directionMultiplier * 50, y: 0});
const blockType = await getNthBlockType(browser, categoryName, i);
dragBlockTypeFromFlyout(browser, categoryName, blockType, 50, 20);
await browser.pause(PAUSE_TIME);
// Should be one top level block on the workspace.
const topBlockCount = await browser.execute(() => {
@@ -178,7 +174,9 @@ async function openCategories(browser, categoryList, directionMultiplier) {
chai.assert.equal(failureCount, 0);
}
suite('Open toolbox categories', function () {
// 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 () {
this.timeout(0);
test('opening every toolbox category in the category toolbox in LTR', async function () {

View File

@@ -206,13 +206,13 @@ suite('Workspace comments', function () {
'.blocklyComment .blocklyResizeHandle',
);
await resizeHandle.dragAndDrop(delta);
chai.assert.deepEqual(
await getCommentSize(this.browser, commentId),
{
width: origSize.width + delta.x,
height: origSize.height + delta.y,
},
const newSize = await getCommentSize(this.browser, commentId);
chai.assert.isTrue(
Math.abs(newSize.width - (origSize.width + delta.x)) < 1,
'Expected the comment model size to match the resized size',
);
chai.assert.isTrue(
Math.abs(newSize.height - (origSize.height + delta.y)) < 1,
'Expected the comment model size to match the resized size',
);
});

View File

@@ -7,7 +7,10 @@
import {ConnectionType} from '../../build/src/core/connection_type.js';
import {EventType} from '../../build/src/core/events/type.js';
import * as eventUtils from '../../build/src/core/events/utils.js';
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 {createRenderedBlock} from './test_helpers/block_definitions.js';
import {
@@ -1426,9 +1429,9 @@ suite('Blocks', function () {
});
suite('Constructing registered comment classes', function () {
class MockComment extends MockIcon {
class MockComment extends MockBubbleIcon {
getType() {
return Blockly.icons.IconType.COMMENT;
return IconType.COMMENT;
}
setText() {}
@@ -1440,19 +1443,13 @@ suite('Blocks', function () {
setBubbleSize() {}
getBubbleSize() {
return Blockly.utils.Size(0, 0);
return Size(0, 0);
}
setBubbleLocation() {}
getBubbleLocation() {}
bubbleIsVisible() {
return true;
}
setBubbleVisible() {}
saveState() {
return {};
}
@@ -1460,6 +1457,10 @@ suite('Blocks', function () {
loadState() {}
}
if (!isCommentIcon(new MockComment())) {
throw new TypeError('MockComment not an ICommentIcon');
}
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {});

View File

@@ -76,7 +76,7 @@ suite('Clipboard', function () {
await mutatorIcon.setBubbleVisible(true);
const mutatorWorkspace = mutatorIcon.getWorkspace();
const elseIf = mutatorWorkspace.getBlocksByType('controls_if_elseif')[0];
assert.notEqual(elseIf, undefined);
assert.isDefined(elseIf);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
const data = elseIf.toCopyData();
@@ -85,6 +85,34 @@ suite('Clipboard', function () {
assert.lengthOf(this.workspace.getAllBlocks(), 1);
});
test('pasting into a mutator flyout pastes into the mutator workspace', async function () {
const block = Blockly.serialization.blocks.append(
{
'type': 'controls_if',
'id': 'blockId',
'extraState': {
'elseIfCount': 1,
},
},
this.workspace,
);
const mutatorIcon = block.getIcon(Blockly.icons.IconType.MUTATOR);
await mutatorIcon.setBubbleVisible(true);
const mutatorWorkspace = mutatorIcon.getWorkspace();
const mutatorFlyoutWorkspace = mutatorWorkspace
.getFlyout()
.getWorkspace();
const elseIf =
mutatorFlyoutWorkspace.getBlocksByType('controls_if_elseif')[0];
assert.isDefined(elseIf);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
const data = elseIf.toCopyData();
Blockly.clipboard.paste(data, mutatorFlyoutWorkspace);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 3);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
});
suite('pasted blocks are placed in unambiguous locations', function () {
test('pasted blocks are bumped to not overlap', function () {
const block = Blockly.serialization.blocks.append(
@@ -139,8 +167,7 @@ suite('Clipboard', function () {
});
suite('pasting comments', function () {
// TODO: Reenable test when we readd copy-paste.
test.skip('pasted comments are bumped to not overlap', function () {
test('pasted comments are bumped to not overlap', function () {
Blockly.Xml.domToWorkspace(
Blockly.utils.xml.textToDom(
'<xml><comment id="test" x=10 y=10/></xml>',
@@ -153,7 +180,7 @@ suite('Clipboard', function () {
const newComment = Blockly.clipboard.paste(data, this.workspace);
assert.deepEqual(
newComment.getRelativeToSurfaceXY(),
new Blockly.utils.Coordinate(60, 60),
new Blockly.utils.Coordinate(40, 40),
);
});
});

View File

@@ -29,7 +29,10 @@ suite('Connection checker', function () {
}
test('Target Null', function () {
const connection = new Blockly.Connection({}, ConnectionType.INPUT_VALUE);
const connection = new Blockly.Connection(
{id: 'test'},
ConnectionType.INPUT_VALUE,
);
assertReasonHelper(
this.checker,
connection,
@@ -38,7 +41,7 @@ suite('Connection checker', function () {
);
});
test('Target Self', function () {
const block = {workspace: 1};
const block = {id: 'test', workspace: 1};
const connection1 = new Blockly.Connection(
block,
ConnectionType.INPUT_VALUE,
@@ -57,11 +60,11 @@ suite('Connection checker', function () {
});
test('Different Workspaces', function () {
const connection1 = new Blockly.Connection(
{workspace: 1},
{id: 'test1', workspace: 1},
ConnectionType.INPUT_VALUE,
);
const connection2 = new Blockly.Connection(
{workspace: 2},
{id: 'test2', workspace: 2},
ConnectionType.OUTPUT_VALUE,
);
@@ -76,10 +79,10 @@ suite('Connection checker', function () {
setup(function () {
// We have to declare each separately so that the connections belong
// on different blocks.
const prevBlock = {isShadow: function () {}};
const nextBlock = {isShadow: function () {}};
const outBlock = {isShadow: function () {}};
const inBlock = {isShadow: function () {}};
const prevBlock = {id: 'test1', isShadow: function () {}};
const nextBlock = {id: 'test2', isShadow: function () {}};
const outBlock = {id: 'test3', isShadow: function () {}};
const inBlock = {id: 'test4', isShadow: function () {}};
this.previous = new Blockly.Connection(
prevBlock,
ConnectionType.PREVIOUS_STATEMENT,
@@ -197,11 +200,13 @@ suite('Connection checker', function () {
suite('Shadows', function () {
test('Previous Shadow', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const nextBlock = {
id: 'test2',
isShadow: function () {
return false;
},
@@ -224,11 +229,13 @@ suite('Connection checker', function () {
});
test('Next Shadow', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {
return false;
},
};
const nextBlock = {
id: 'test2',
isShadow: function () {
return true;
},
@@ -251,11 +258,13 @@ suite('Connection checker', function () {
});
test('Prev and Next Shadow', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const nextBlock = {
id: 'test2',
isShadow: function () {
return true;
},
@@ -278,11 +287,13 @@ suite('Connection checker', function () {
});
test('Output Shadow', function () {
const outBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const inBlock = {
id: 'test2',
isShadow: function () {
return false;
},
@@ -305,11 +316,13 @@ suite('Connection checker', function () {
});
test('Input Shadow', function () {
const outBlock = {
id: 'test1',
isShadow: function () {
return false;
},
};
const inBlock = {
id: 'test2',
isShadow: function () {
return true;
},
@@ -332,11 +345,13 @@ suite('Connection checker', function () {
});
test('Output and Input Shadow', function () {
const outBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const inBlock = {
id: 'test2',
isShadow: function () {
return true;
},
@@ -373,9 +388,11 @@ suite('Connection checker', function () {
};
test('Output connected, adding previous', function () {
const outBlock = {
id: 'test1',
isShadow: function () {},
};
const inBlock = {
id: 'test2',
isShadow: function () {},
};
const outCon = new Blockly.Connection(
@@ -394,6 +411,7 @@ suite('Connection checker', function () {
ConnectionType.PREVIOUS_STATEMENT,
);
const nextBlock = {
id: 'test3',
isShadow: function () {},
};
const nextCon = new Blockly.Connection(
@@ -410,9 +428,11 @@ suite('Connection checker', function () {
});
test('Previous connected, adding output', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {},
};
const nextBlock = {
id: 'test2',
isShadow: function () {},
};
const prevCon = new Blockly.Connection(
@@ -431,6 +451,7 @@ suite('Connection checker', function () {
ConnectionType.OUTPUT_VALUE,
);
const inBlock = {
id: 'test3',
isShadow: function () {},
};
const inCon = new Blockly.Connection(
@@ -449,8 +470,14 @@ suite('Connection checker', function () {
});
suite('Check Types', function () {
setup(function () {
this.con1 = new Blockly.Connection({}, ConnectionType.PREVIOUS_STATEMENT);
this.con2 = new Blockly.Connection({}, ConnectionType.NEXT_STATEMENT);
this.con1 = new Blockly.Connection(
{id: 'test1'},
ConnectionType.PREVIOUS_STATEMENT,
);
this.con2 = new Blockly.Connection(
{id: 'test2'},
ConnectionType.NEXT_STATEMENT,
);
});
function assertCheckTypes(checker, one, two) {
assert.isTrue(checker.doTypeChecks(one, two));

View File

@@ -5,6 +5,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 {
sharedTestSetup,
@@ -31,7 +32,7 @@ suite('Connection Database', function () {
};
workspace.connectionDBList[type] = opt_database || this.database;
const connection = new Blockly.RenderedConnection(
{workspace: workspace},
{id: idGenerator.getNextUniqueId(), workspace: workspace},
type,
);
connection.x = x;

View File

@@ -60,6 +60,33 @@ suite('Cursor', function () {
'tooltip': '',
'helpUrl': '',
},
{
'type': 'multi_statement_input',
'message0': '%1 %2',
'args0': [
{
'type': 'input_statement',
'name': 'FIRST',
},
{
'type': 'input_statement',
'name': 'SECOND',
},
],
},
{
'type': 'simple_statement',
'message0': '%1',
'args0': [
{
'type': 'field_input',
'name': 'NAME',
'text': 'default',
},
],
'previousStatement': null,
'nextStatement': null,
},
]);
this.workspace = Blockly.inject('blocklyDiv', {});
this.cursor = this.workspace.getCursor();
@@ -145,6 +172,112 @@ suite('Cursor', function () {
assert.equal(curNode, this.blocks.D.nextConnection);
});
});
suite('Multiple statement inputs', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.defineBlocksWithJsonArray([
{
'type': 'multi_statement_input',
'message0': '%1 %2',
'args0': [
{
'type': 'input_statement',
'name': 'FIRST',
},
{
'type': 'input_statement',
'name': 'SECOND',
},
],
},
{
'type': 'simple_statement',
'message0': '%1',
'args0': [
{
'type': 'field_input',
'name': 'NAME',
'text': 'default',
},
],
'previousStatement': null,
'nextStatement': null,
},
]);
this.workspace = Blockly.inject('blocklyDiv', {});
this.cursor = this.workspace.getCursor();
this.multiStatement1 = createRenderedBlock(
this.workspace,
'multi_statement_input',
);
this.multiStatement2 = createRenderedBlock(
this.workspace,
'multi_statement_input',
);
this.firstStatement = createRenderedBlock(
this.workspace,
'simple_statement',
);
this.secondStatement = createRenderedBlock(
this.workspace,
'simple_statement',
);
this.thirdStatement = createRenderedBlock(
this.workspace,
'simple_statement',
);
this.fourthStatement = createRenderedBlock(
this.workspace,
'simple_statement',
);
this.multiStatement1
.getInput('FIRST')
.connection.connect(this.firstStatement.previousConnection);
this.firstStatement.nextConnection.connect(
this.secondStatement.previousConnection,
);
this.multiStatement1
.getInput('SECOND')
.connection.connect(this.thirdStatement.previousConnection);
this.multiStatement2
.getInput('FIRST')
.connection.connect(this.fourthStatement.previousConnection);
});
teardown(function () {
sharedTestTeardown.call(this);
});
test('In - from field in nested statement block to next nested statement block', function () {
this.cursor.setCurNode(this.secondStatement.getField('NAME'));
this.cursor.in();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.thirdStatement);
});
test('In - from field in nested statement block to next stack', function () {
this.cursor.setCurNode(this.thirdStatement.getField('NAME'));
this.cursor.in();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.multiStatement2);
});
test('Out - from nested statement block to last field of previous nested statement block', function () {
this.cursor.setCurNode(this.thirdStatement);
this.cursor.out();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.secondStatement.getField('NAME'));
});
test('Out - from root block to last field of last nested statement block in previous stack', function () {
this.cursor.setCurNode(this.multiStatement2);
this.cursor.out();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.thirdStatement.getField('NAME'));
});
});
suite('Searching', function () {
setup(function () {
sharedTestSetup.call(this);

View File

@@ -355,6 +355,7 @@ suite('Events', function () {
suite('With variable getter blocks', function () {
setup(function () {
this.TEST_BLOCK_ID = 'test_block_id';
this.genUidStub = createGenUidStubWithReturns([
this.TEST_BLOCK_ID,
'test_var_id',

View File

@@ -294,4 +294,300 @@ suite('Text Input Fields', function () {
this.assertValue('test text');
});
});
suite('Use editor', function () {
setup(function () {
this.blockJson = {
'type': 'math_arithmetic',
'id': 'test_arithmetic_block',
'fields': {
'OP': 'ADD',
},
'inputs': {
'A': {
'shadow': {
'type': 'math_number',
'id': 'left_input_block',
'name': 'test_name',
'fields': {
'NUM': 1,
},
},
},
'B': {
'shadow': {
'type': 'math_number',
'id': 'right_input_block',
'fields': {
'NUM': 2,
},
},
},
},
};
this.getFieldFromShadowBlock = function (shadowBlock) {
return shadowBlock.getFields().next().value;
};
this.simulateTypingIntoInput = (inputElem, newText) => {
// Typing into an input field changes its value directly and then fires
// an InputEvent (which FieldInput relies on to automatically
// synchronize its state).
inputElem.value = newText;
inputElem.dispatchEvent(new InputEvent('input'));
};
});
// The block being tested doesn't use full-block fields in Geras.
suite('Geras theme', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
Blockly.serialization.blocks.append(this.blockJson, this.workspace);
// The workspace actually needs to be visible for focus.
document.getElementById('blocklyDiv').style.visibility = 'visible';
});
teardown(function () {
document.getElementById('blocklyDiv').style.visibility = 'hidden';
workspaceTeardown.call(this, this.workspace);
});
test('No editor open by default', function () {
// The editor is only opened if its indicated that it should be open.
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Type in editor with escape does not change field value', async function () {
const block = this.workspace.getBlockById('left_input_block');
const field = this.getFieldFromShadowBlock(block);
field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(fieldEditor, 'updated value');
fieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
}),
);
// 'Escape' will avoid saving the edited field value and close the editor.
assert.equal(field.getValue(), 1);
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Type in editor with enter changes field value', async function () {
const block = this.workspace.getBlockById('left_input_block');
const field = this.getFieldFromShadowBlock(block);
field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(fieldEditor, '10');
fieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// 'Enter' will save the edited result and close the editor.
assert.equal(field.getValue(), 10);
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Not finishing editing does not return ephemeral focus', async function () {
const block = this.workspace.getBlockById('left_input_block');
const field = this.getFieldFromShadowBlock(block);
Blockly.getFocusManager().focusNode(field);
field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(fieldEditor, '10');
// If the editor doesn't restore focus then the current focused element is
// still the editor.
assert.strictEqual(document.activeElement, fieldEditor);
});
test('Finishing editing returns ephemeral focus', async function () {
const block = this.workspace.getBlockById('left_input_block');
const field = this.getFieldFromShadowBlock(block);
Blockly.getFocusManager().focusNode(field);
field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(fieldEditor, '10');
fieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
}),
);
// Verify that exiting the editor restores focus back to the field.
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), field);
assert.strictEqual(document.activeElement, field.getFocusableElement());
});
test('Opening an editor, tabbing, then editing changes the second field', async function () {
const leftInputBlock = this.workspace.getBlockById('left_input_block');
const rightInputBlock =
this.workspace.getBlockById('right_input_block');
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
const rightField = this.getFieldFromShadowBlock(rightInputBlock);
leftField.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Tab, then edit and close the editor.
document.querySelector('.blocklyHtmlInput').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
}),
);
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(rightFieldEditor, '15');
rightFieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// Verify that only the right field changed (due to the tab).
assert.equal(leftField.getValue(), 1);
assert.equal(rightField.getValue(), 15);
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Opening an editor, tabbing, then editing changes focus to the second field', async function () {
const leftInputBlock = this.workspace.getBlockById('left_input_block');
const rightInputBlock =
this.workspace.getBlockById('right_input_block');
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
const rightField = this.getFieldFromShadowBlock(rightInputBlock);
Blockly.getFocusManager().focusNode(leftField);
leftField.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Tab, then edit and close the editor.
document.querySelector('.blocklyHtmlInput').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
}),
);
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(rightFieldEditor, '15');
rightFieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// Verify that the tab causes focus to change to the right field.
assert.strictEqual(
Blockly.getFocusManager().getFocusedNode(),
rightField,
);
assert.strictEqual(
document.activeElement,
rightField.getFocusableElement(),
);
});
});
// The block being tested uses full-block fields in Zelos.
suite('Zelos theme', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'zelos',
});
Blockly.serialization.blocks.append(this.blockJson, this.workspace);
// The workspace actually needs to be visible for focus.
document.getElementById('blocklyDiv').style.visibility = 'visible';
});
teardown(function () {
document.getElementById('blocklyDiv').style.visibility = 'hidden';
workspaceTeardown.call(this, this.workspace);
});
test('Opening an editor, tabbing, then editing full block field changes the second field', async function () {
const leftInputBlock = this.workspace.getBlockById('left_input_block');
const rightInputBlock =
this.workspace.getBlockById('right_input_block');
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
const rightField = this.getFieldFromShadowBlock(rightInputBlock);
leftField.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Tab, then edit and close the editor.
document.querySelector('.blocklyHtmlInput').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
}),
);
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(rightFieldEditor, '15');
rightFieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// Verify that only the right field changed (due to the tab).
assert.equal(leftField.getValue(), 1);
assert.equal(rightField.getValue(), 15);
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Opening an editor, tabbing, then editing full block field changes focus to the second field', async function () {
const leftInputBlock = this.workspace.getBlockById('left_input_block');
const rightInputBlock =
this.workspace.getBlockById('right_input_block');
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
Blockly.getFocusManager().focusNode(leftInputBlock);
leftField.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Tab, then edit and close the editor.
document.querySelector('.blocklyHtmlInput').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
}),
);
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(rightFieldEditor, '15');
rightFieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// Verify that the tab causes focus to change to the right field block.
assert.strictEqual(
Blockly.getFocusManager().getFocusedNode(),
rightInputBlock,
);
assert.strictEqual(
document.activeElement,
rightInputBlock.getFocusableElement(),
);
});
});
});
});

View File

@@ -249,6 +249,54 @@ suite('FocusManager', function () {
// The second register should not fail since the tree was previously unregistered.
});
test('for tree with missing ID throws error', function () {
const rootNode = this.testFocusableTree1.getRootFocusableNode();
const rootElem = rootNode.getFocusableElement();
const oldId = rootElem.id;
rootElem.removeAttribute('id');
const errorMsgRegex =
/Attempting to register a tree with a root element that has an invalid ID.+?/;
assert.throws(
() => this.focusManager.registerTree(this.testFocusableTree1),
errorMsgRegex,
);
// Restore the ID for other tests.
rootElem.id = oldId;
});
test('for tree with null ID throws error', function () {
const rootNode = this.testFocusableTree1.getRootFocusableNode();
const rootElem = rootNode.getFocusableElement();
const oldId = rootElem.id;
rootElem.setAttribute('id', null);
const errorMsgRegex =
/Attempting to register a tree with a root element that has an invalid ID.+?/;
assert.throws(
() => this.focusManager.registerTree(this.testFocusableTree1),
errorMsgRegex,
);
// Restore the ID for other tests.
rootElem.id = oldId;
});
test('for tree with empty throws error', function () {
const rootNode = this.testFocusableTree1.getRootFocusableNode();
const rootElem = rootNode.getFocusableElement();
const oldId = rootElem.id;
rootElem.setAttribute('id', '');
const errorMsgRegex =
/Attempting to register a tree with a root element that has an invalid ID.+?/;
assert.throws(
() => this.focusManager.registerTree(this.testFocusableTree1),
errorMsgRegex,
);
// Restore the ID for other tests.
rootElem.id = oldId;
});
test('for unmanaged tree does not overwrite tab index', function () {
this.focusManager.registerTree(this.testFocusableTree1, false);

View File

@@ -348,6 +348,80 @@ suite('FocusableTreeTraverser', function () {
});
suite('findFocusableNodeFor()', function () {
test('for element without ID returns null', function () {
const tree = this.testFocusableTree1;
const rootNode = tree.getRootFocusableNode();
const rootElem = rootNode.getFocusableElement();
const oldId = rootElem.id;
// Normally it's not valid to miss an ID, but it can realistically happen.
rootElem.removeAttribute('id');
const finding = FocusableTreeTraverser.findFocusableNodeFor(
rootElem,
tree,
);
// Restore the ID for other tests.
rootElem.setAttribute('id', oldId);
assert.isNull(finding);
});
test('for element with null ID returns null', function () {
const tree = this.testFocusableTree1;
const rootNode = tree.getRootFocusableNode();
const rootElem = rootNode.getFocusableElement();
const oldId = rootElem.id;
// Normally it's not valid to miss an ID, but it can realistically happen.
rootElem.setAttribute('id', null);
const finding = FocusableTreeTraverser.findFocusableNodeFor(
rootElem,
tree,
);
// Restore the ID for other tests.
rootElem.setAttribute('id', oldId);
assert.isNull(finding);
});
test('for element with null ID string returns null', function () {
const tree = this.testFocusableTree1;
const rootNode = tree.getRootFocusableNode();
const rootElem = rootNode.getFocusableElement();
const oldId = rootElem.id;
// This is a quirky version of the null variety above that's actually
// functionallity equivalent (since 'null' is converted to a string).
rootElem.setAttribute('id', 'null');
const finding = FocusableTreeTraverser.findFocusableNodeFor(
rootElem,
tree,
);
// Restore the ID for other tests.
rootElem.setAttribute('id', oldId);
assert.isNull(finding);
});
test('for element with empty ID returns null', function () {
const tree = this.testFocusableTree1;
const rootNode = tree.getRootFocusableNode();
const rootElem = rootNode.getFocusableElement();
const oldId = rootElem.id;
// An empty ID is invalid since it will potentially conflict with other
// elements, and element IDs must be unique for focus management.
rootElem.setAttribute('id', '');
const finding = FocusableTreeTraverser.findFocusableNodeFor(
rootElem,
tree,
);
// Restore the ID for other tests.
rootElem.setAttribute('id', oldId);
assert.isNull(finding);
});
test('for root element returns root', function () {
const tree = this.testFocusableTree1;
const rootNode = tree.getRootFocusableNode();

View File

@@ -5,6 +5,7 @@
*/
import * as Blockly from '../../build/src/core/blockly.js';
import {assert} from '../../node_modules/chai/chai.js';
import {defineStackBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,
@@ -47,6 +48,16 @@ suite('Keyboard Shortcut Items', function () {
.returns(block.nextConnection);
}
/**
* Creates a workspace comment and set it as the focused node.
* @param {Blockly.Workspace} workspace The workspace to create a new comment on.
*/
function setSelectedComment(workspace) {
const comment = workspace.newComment();
sinon.stub(Blockly.getFocusManager(), 'getFocusedNode').returns(comment);
return comment;
}
/**
* Creates a test for not running keyDown events when the workspace is in read only mode.
* @param {Object} keyEvent Mocked key down event. Use createKeyDownEvent.
@@ -173,12 +184,17 @@ suite('Keyboard Shortcut Items', function () {
});
});
});
// Do not copy a block if a workspace is in readonly mode.
suite('Not called when readOnly is true', function () {
// Allow copying a block if a workspace is in readonly mode.
suite('Called when readOnly is true', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
runReadOnlyTest(keyEvent, testCaseName);
test(testCaseName, function () {
this.workspace.setIsReadOnly(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.hideChaffSpy);
});
});
});
// Do not copy a block if a drag is in progress.
@@ -236,6 +252,165 @@ suite('Keyboard Shortcut Items', function () {
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
// Copy a comment.
test('Workspace comment', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
Blockly.getFocusManager().getFocusedNode.restore();
this.comment = setSelectedComment(this.workspace);
this.copySpy = sinon.spy(this.comment, 'toCopyData');
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.hideChaffSpy);
});
});
});
});
suite('Cut', function () {
setup(function () {
this.block = setSelectedBlock(this.workspace);
this.copySpy = sinon.spy(this.block, 'toCopyData');
this.disposeSpy = sinon.spy(this.block, 'dispose');
this.hideChaffSpy = sinon.spy(
Blockly.WorkspaceSvg.prototype,
'hideChaff',
);
});
const testCases = [
[
'Control X',
createKeyDownEvent(Blockly.utils.KeyCodes.X, [
Blockly.utils.KeyCodes.CTRL,
]),
],
[
'Meta X',
createKeyDownEvent(Blockly.utils.KeyCodes.X, [
Blockly.utils.KeyCodes.META,
]),
],
];
// Cut a block.
suite('Simple', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.disposeSpy);
sinon.assert.calledOnce(this.hideChaffSpy);
});
});
});
// Do not cut a block if a workspace is in readonly mode.
suite('Not called when readOnly is true', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
this.workspace.setIsReadOnly(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
// Do not cut a block if a drag is in progress.
suite('Drag in progress', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon.stub(this.workspace, 'isDragging').returns(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
// Do not cut a block if is is not deletable.
suite('Block is not deletable', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon
.stub(Blockly.common.getSelected(), 'isOwnDeletable')
.returns(false);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
// Do not cut a block if it is not movable.
suite('Block is not movable', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon
.stub(Blockly.common.getSelected(), 'isOwnMovable')
.returns(false);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
});
});
test('Not called when connection is focused', function () {
// Restore the stub behavior called during setup
Blockly.getFocusManager().getFocusedNode.restore();
setSelectedConnection(this.workspace);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [
Blockly.utils.KeyCodes.CTRL,
]);
this.injectionDiv.dispatchEvent(event);
sinon.assert.notCalled(this.copySpy);
sinon.assert.notCalled(this.disposeSpy);
sinon.assert.notCalled(this.hideChaffSpy);
});
// Cut a comment.
suite('Workspace comment', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
Blockly.getFocusManager().getFocusedNode.restore();
this.comment = setSelectedComment(this.workspace);
this.copySpy = sinon.spy(this.comment, 'toCopyData');
this.disposeSpy = sinon.spy(this.comment, 'dispose');
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.calledOnce(this.copySpy);
sinon.assert.calledOnce(this.disposeSpy);
});
});
});
});
suite('Paste', function () {
test('Disabled when nothing has been copied', function () {
const pasteShortcut =
Blockly.ShortcutRegistry.registry.getRegistry()[
Blockly.ShortcutItems.names.PASTE
];
Blockly.clipboard.setLastCopiedData(undefined);
const isPasteEnabled = pasteShortcut.preconditionFn();
assert.isFalse(isPasteEnabled);
});
});
suite('Undo', function () {
@@ -273,13 +448,13 @@ suite('Keyboard Shortcut Items', function () {
});
});
});
// Do not undo if a gesture is in progress.
suite('Gesture in progress', function () {
// Do not undo if a drag is in progress.
suite('Drag in progress', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon.stub(Blockly.Gesture, 'inProgress').returns(true);
sinon.stub(this.workspace, 'isDragging').returns(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.undoSpy);
sinon.assert.notCalled(this.hideChaffSpy);
@@ -333,13 +508,13 @@ suite('Keyboard Shortcut Items', function () {
});
});
});
// Do not undo if a gesture is in progress.
suite('Gesture in progress', function () {
// Do not redo if a drag is in progress.
suite('Drag in progress', function () {
testCases.forEach(function (testCase) {
const testCaseName = testCase[0];
const keyEvent = testCase[1];
test(testCaseName, function () {
sinon.stub(Blockly.Gesture, 'inProgress').returns(true);
sinon.stub(this.workspace, 'isDragging').returns(true);
this.injectionDiv.dispatchEvent(keyEvent);
sinon.assert.notCalled(this.redoSpy);
sinon.assert.notCalled(this.hideChaffSpy);
@@ -373,8 +548,8 @@ suite('Keyboard Shortcut Items', function () {
sinon.assert.calledWith(this.undoSpy, true);
sinon.assert.calledOnce(this.hideChaffSpy);
});
test('Not called when a gesture is in progress', function () {
sinon.stub(Blockly.Gesture, 'inProgress').returns(true);
test('Not called when a drag is in progress', function () {
sinon.stub(this.workspace, 'isDragging').returns(true);
this.injectionDiv.dispatchEvent(this.ctrlYEvent);
sinon.assert.notCalled(this.undoSpy);
sinon.assert.notCalled(this.hideChaffSpy);

View File

@@ -4,7 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
export class MockIcon {
import {isFocusableNode} from '../../../build/src/core/interfaces/i_focusable_node.js';
import {hasBubble} from '../../../build/src/core/interfaces/i_has_bubble.js';
import {isIcon} from '../../../build/src/core/interfaces/i_icon.js';
import {isSerializable} from '../../../build/src/core/interfaces/i_serializable.js';
export class MockFocusable {
getFocusableElement() {}
getFocusableTree() {}
onNodeFocus() {}
onNodeBlur() {}
canBeFocused() {}
}
if (!isFocusableNode(new MockFocusable())) {
throw new TypeError('MockFocusable not an IFocuableNode');
}
export class MockIcon extends MockFocusable {
getType() {
return new Blockly.icons.IconType('mock icon');
}
@@ -52,6 +69,10 @@ export class MockIcon {
}
}
if (!isIcon(new MockIcon())) {
throw new TypeError('MockIcon not an IIcon');
}
export class MockSerializableIcon extends MockIcon {
constructor() {
super();
@@ -75,6 +96,10 @@ export class MockSerializableIcon extends MockIcon {
}
}
if (!isSerializable(new MockSerializableIcon())) {
throw new TypeError('MockSerializableIcon not an ISerializable');
}
export class MockBubbleIcon extends MockIcon {
constructor() {
super();
@@ -94,4 +119,12 @@ export class MockBubbleIcon extends MockIcon {
setBubbleVisible(visible) {
this.visible = visible;
}
getBubble() {
return null;
}
}
if (!hasBubble(new MockBubbleIcon())) {
throw new TypeError('MockBubbleIcon not an IHasBubble');
}

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {
assertEventFired,
createChangeListenerSpy,
@@ -167,5 +168,15 @@ suite('Workspace comment', function () {
this.workspace.id,
);
});
test('focuses the workspace when deleted', function () {
const comment = new Blockly.comments.RenderedWorkspaceComment(
this.workspace,
);
Blockly.getFocusManager().focusNode(comment);
assert.equal(Blockly.getFocusManager().getFocusedNode(), comment);
comment.view.getCommentBarButtons()[1].performAction();
assert.equal(Blockly.getFocusManager().getFocusedNode(), this.workspace);
});
});
});