release: merge develop into rv/v12.2.0

This commit is contained in:
Maribeth Moffatt
2025-07-09 13:40:03 -07:00
54 changed files with 2067 additions and 1761 deletions

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 {

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,7 +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

@@ -52,6 +52,7 @@ export class CommentEditor implements IFocusableNode {
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');

View File

@@ -16,14 +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;
@@ -33,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;
@@ -99,7 +102,7 @@ export class CommentView implements IRenderedElement {
constructor(
readonly workspace: WorkspaceSvg,
private commentId?: string,
private commentId: string,
) {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
@@ -110,11 +113,11 @@ 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));
this.commentEditor = this.createTextArea();
@@ -147,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;
} {
@@ -172,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(
@@ -200,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,
};
@@ -300,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}`);
@@ -317,15 +288,9 @@ export class CommentView implements IRenderedElement {
this.updateHighlightRect(size);
this.updateTopBarSize(size);
this.commentEditor.updateSize(size, topBarSize);
this.updateDeleteIconPosition(size, topBarSize, deleteSize);
this.updateFoldoutIconPosition(topBarSize, foldoutSize);
this.updateTextPreviewSize(
size,
topBarSize,
textPreviewSize,
deleteSize,
resizeSize,
);
this.deleteButton.reposition();
this.foldoutButton.reposition();
this.updateTextPreviewSize(size, topBarSize, textPreviewSize);
this.updateResizeHandlePosition(size, resizeSize);
}
@@ -347,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 {
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.
}
@@ -376,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}`);
@@ -400,31 +348,6 @@ export class CommentView implements IRenderedElement {
this.topBarBackground.setAttribute('width', `${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.
*/
@@ -432,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}`,
@@ -601,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;
@@ -692,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.
@@ -719,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--) {
@@ -749,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(`

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

@@ -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

@@ -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';
@@ -143,21 +146,25 @@ function getBlockNavigationCandidates(
}
/**
* 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) {
@@ -166,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;
}

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

@@ -14,6 +14,7 @@
*/
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';
@@ -38,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();
@@ -53,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,
@@ -87,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();
@@ -102,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,
@@ -398,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

@@ -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(),
];
/**

View File

@@ -11,8 +11,7 @@ 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 {ICopyData, isCopyable as isICopyable} from './interfaces/i_copyable.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';
@@ -67,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()
);
@@ -93,9 +92,6 @@ export function registerDelete() {
ShortcutRegistry.registry.register(deleteShortcut);
}
let copyData: ICopyData | null = null;
let copyCoords: Coordinate | null = null;
/**
* Determine if a focusable node can be copied.
*
@@ -176,12 +172,12 @@ export function registerCopy() {
if (!focused.workspace.isFlyout) {
targetWorkspace.hideChaff();
}
copyData = focused.toCopyData();
copyCoords =
const copyCoords =
isDraggable(focused) && focused.workspace == targetWorkspace
? focused.getRelativeToSurfaceXY()
: null;
return !!copyData;
: undefined;
return !!clipboard.copy(focused, copyCoords);
},
keyCodes: [ctrlC, metaC],
};
@@ -216,10 +212,10 @@ export function registerCut() {
if (!focused || !isCuttable(focused) || !isICopyable(focused)) {
return false;
}
copyData = focused.toCopyData();
copyCoords = isDraggable(focused)
const copyCoords = isDraggable(focused)
? focused.getRelativeToSurfaceXY()
: null;
: undefined;
const copyData = clipboard.copy(focused, copyCoords);
if (focused instanceof BlockSvg) {
focused.checkAndDelete();
@@ -247,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() &&
@@ -260,10 +263,15 @@ export function registerPaste() {
);
},
callback(workspace: WorkspaceSvg, e: Event) {
const copyData = clipboard.getLastCopiedData();
if (!copyData) return false;
const targetWorkspace = workspace.isFlyout
? workspace.targetWorkspace
: workspace;
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) {
@@ -279,6 +287,7 @@ export function registerPaste() {
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.
@@ -322,7 +331,7 @@ export function registerUndo() {
preconditionFn(workspace) {
return (
!workspace.isReadOnly() &&
!Gesture.inProgress() &&
!workspace.isDragging() &&
!getFocusManager().ephemeralFocusTaken()
);
},
@@ -360,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,7 +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';
@@ -478,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);
}
/**
@@ -489,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();
}
/**
@@ -897,10 +893,7 @@ export class WorkspaceSvg
}
this.renderer.dispose();
if (this.markerManager) {
this.markerManager.dispose();
}
this.markerManager.dispose();
super.dispose();
@@ -2266,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);
}
@@ -2276,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;
}
@@ -2308,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;
}
/**
@@ -2794,19 +2814,32 @@ export class WorkspaceSvg
return null;
}
// Search for a specific workspace comment editor
// (only if id seems like it is one).
const commentEditorIndicator = id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER);
if (commentEditorIndicator !== -1) {
const commentId = id.substring(0, commentEditorIndicator);
// 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) {
return comment.getEditorFocusableNode();
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 guaranteeed
// 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,

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,
}

1372
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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

@@ -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

@@ -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,
@@ -399,6 +400,19 @@ suite('Keyboard Shortcut Items', function () {
});
});
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 () {
setup(function () {
this.undoSpy = sinon.spy(this.workspace, 'undo');
@@ -434,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);
@@ -494,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);
@@ -534,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,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);
});
});
});